From f23b65d3d8e5c8343d7d5c12103091c8024ca67b Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Wed, 22 Apr 2020 17:24:15 +0200 Subject: [PATCH 01/83] implemented first tests and basic functionality --- .idea/codeStyles/codeStyleConfig.xml | 5 ++ .idea/dictionaries/VLE2FE.xml | 9 +++ oas/oas.yaml | 9 +++ oas/schemas.yaml | 16 ++--- oas/user.yaml | 14 +++-- package-lock.json | 48 +++++++++++++++ package.json | 3 + src/db.ts | 90 ++++++++++++++++++++++++++++ src/globals.ts | 11 ++++ src/index.ts | 56 ++++++++--------- src/models/user.ts | 13 ++++ src/routes/root.spec.ts | 53 +++++++++++++--- src/routes/user.spec.ts | 75 +++++++++++++++++++++++ src/routes/user.ts | 31 ++++++++++ src/routes/validate/user.ts | 44 ++++++++++++++ src/test/db.json | 17 ++++++ tsconfig.json | 6 +- 17 files changed, 451 insertions(+), 49 deletions(-) create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/dictionaries/VLE2FE.xml create mode 100644 src/db.ts create mode 100644 src/globals.ts create mode 100644 src/models/user.ts create mode 100644 src/routes/user.spec.ts create mode 100644 src/routes/user.ts create mode 100644 src/routes/validate/user.ts create mode 100644 src/test/db.json 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" From 90d34f1e1b27a1b35143247cef203850f28a41d7 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Wed, 22 Apr 2020 17:38:24 +0200 Subject: [PATCH 02/83] cannot add username twice --- oas/others.yaml | 2 +- src/db.ts | 4 +--- src/index.ts | 4 ++-- src/routes/root.spec.ts | 3 ++- src/routes/root.ts | 2 +- src/routes/user.spec.ts | 10 ++++++---- src/routes/user.ts | 21 +++++++++++++++------ 7 files changed, 28 insertions(+), 18 deletions(-) diff --git a/oas/others.yaml b/oas/others.yaml index df322fc..e5f200e 100644 --- a/oas/others.yaml +++ b/oas/others.yaml @@ -11,7 +11,7 @@ application/json: schema: properties: - message: + status: type: string example: 'API server up and running!' 500: diff --git a/src/db.ts b/src/db.ts index 1ac4222..98c8617 100644 --- a/src/db.ts +++ b/src/db.ts @@ -40,7 +40,7 @@ export default class db { }); mongoose.connection.on('error', console.error.bind(console, 'connection error:')); mongoose.connection.once('open', () => { - console.log(`Connected to ${connectionString}`); + console.log(process.env.NODE_ENV === 'test' ? '' : `Connected to ${connectionString}`); this.state.db = mongoose.connection; done(); }); @@ -60,7 +60,6 @@ export default class db { 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(); } @@ -79,7 +78,6 @@ export default class db { 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(); } diff --git a/src/index.ts b/src/index.ts index 8c4af39..cfaf696 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import db from './db'; // tell if server is running in debug or production environment -console.log(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : '===== DEVELOPMENT ====='); +console.log(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); // mongodb connection @@ -53,7 +53,7 @@ app.use((err, req, res, ignore) => { // internal server error handling // hook up server to port const server = app.listen(port, () => { - console.log(`Listening on http://localhost:${port}`); + console.log(process.env.NODE_ENV === 'test' ? '' : `Listening on http://localhost:${port}`); }); module.exports = server; \ No newline at end of file diff --git a/src/routes/root.spec.ts b/src/routes/root.spec.ts index 0e2d625..276f159 100644 --- a/src/routes/root.spec.ts +++ b/src/routes/root.spec.ts @@ -8,6 +8,7 @@ describe('/', () => { before(done => { process.env.port = '2999'; + process.env.NODE_ENV = 'test'; db.connect('test', done); }); beforeEach(done => { @@ -26,7 +27,7 @@ describe('/', () => { .get('/') .expect('Content-type', /json/) .expect(200, (err, res) => { - should(res.body).be.eql({message: 'API server up and running!'}); + should(res.body).be.eql({status: 'API server up and running!'}); done(); }); }); diff --git a/src/routes/root.ts b/src/routes/root.ts index 896f360..bcbb40b 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -3,7 +3,7 @@ import express from 'express'; const router = express.Router(); router.get('/', (req, res) => { - res.json({message: 'API server up and running!'}); + res.json({status: 'API server up and running!'}); }); module.exports = router; diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index fe5c031..4c50a70 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -1,7 +1,7 @@ import supertest from 'supertest'; import should from 'should/as-function'; import db from '../db'; -import userModel from '../models/user'; +import UserModel from '../models/user'; describe('/user/new', () => { @@ -9,6 +9,7 @@ describe('/user/new', () => { before(done => { process.env.port = '2999'; + process.env.NODE_ENV = 'test'; db.connect('test', done); }); beforeEach(done => { @@ -44,7 +45,7 @@ describe('/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) => { + 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'); @@ -63,9 +64,10 @@ describe('/user/new', () => { 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 => { + .expect(400, (err, res) => { if (err) return done(err); - userModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + should(res.body).be.eql({status: 'Username already taken'}); + UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { if (err) return done(err); should(data).have.lengthOf(1); done(); diff --git a/src/routes/user.ts b/src/routes/user.ts index ea2994f..4c1d8ed 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -18,12 +18,21 @@ router.post('/user/new', (req, res, next) => { 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())); + // check that user does not already exist + UserModel.find({name: user.name}).lean().exec( 'find', (err, data) => { + if (err) next(err); + if (data.length > 0) { + res.status(400).json({status: 'Username already taken'}); + 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())); + }); }); }); }); From 1a3fdc567de3fae13e74358d3ee78ce6e6dcbfb1 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 23 Apr 2020 13:59:45 +0200 Subject: [PATCH 03/83] added authorization --- oas/condition.yaml | 10 ++-- oas/material.yaml | 10 ++-- oas/measurement.yaml | 10 ++-- oas/model.yaml | 8 +-- oas/oas.yaml | 5 +- oas/others.yaml | 25 +++++++++ oas/sample.yaml | 14 +++-- oas/template.yaml | 24 ++++++--- oas/user.yaml | 17 +++--- package-lock.json | 18 +++++++ package.json | 4 ++ src/db.ts | 11 ++++ src/globals.ts | 2 +- src/helpers/authorize.ts | 100 ++++++++++++++++++++++++++++++++++++ src/index.ts | 16 ++++++ src/routes/root.spec.ts | 96 ++++++++++++++++++++++++++++++++-- src/routes/root.ts | 6 +++ src/routes/user.spec.ts | 45 +++++++++++++--- src/routes/user.ts | 3 ++ src/routes/validate/user.ts | 3 +- src/test/db.json | 13 ++++- 21 files changed, 393 insertions(+), 47 deletions(-) create mode 100644 src/helpers/authorize.ts diff --git a/oas/condition.yaml b/oas/condition.yaml index 1259ec1..cca8ca6 100644 --- a/oas/condition.yaml +++ b/oas/condition.yaml @@ -3,7 +3,7 @@ - $ref: 'oas.yaml#/components/parameters/Id' get: summary: TODO condition by id - description: 'levels: read, write, maintain, dev, admin' + description: 'Auth: all, levels: read, write, maintain, dev, admin' tags: - /condition responses: @@ -23,9 +23,11 @@ $ref: 'oas.yaml#/components/responses/500' put: summary: TODO add/change condition - description: 'levels: write, maintain, dev, admin' + description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /condition + security: + - BasicAuth: [] requestBody: required: true content: @@ -51,9 +53,11 @@ $ref: 'oas.yaml#/components/responses/500' delete: summary: TODO delete condition - description: 'levels: write, maintain, dev, admin' + description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /condition + security: + - BasicAuth: [] responses: 200: $ref: 'oas.yaml#/components/responses/Ok' diff --git a/oas/material.yaml b/oas/material.yaml index 2ba26d7..d5d7d34 100644 --- a/oas/material.yaml +++ b/oas/material.yaml @@ -3,7 +3,7 @@ - $ref: 'oas.yaml#/components/parameters/Id' get: summary: TODO get material details - description: 'levels: read, write, maintain, dev, admin' + description: 'Auth: all, levels: read, write, maintain, dev, admin' tags: - /material responses: @@ -21,9 +21,11 @@ $ref: 'oas.yaml#/components/responses/500' put: summary: TODO add/change material - description: 'levels: write, maintain, dev, admin' + description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /material + security: + - BasicAuth: [] requestBody: required: true content: @@ -47,9 +49,11 @@ $ref: 'oas.yaml#/components/responses/500' delete: summary: TODO delete material - description: 'levels: write, maintain, dev, admin' + description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /material + security: + - BasicAuth: [] responses: 200: $ref: 'oas.yaml#/components/responses/Ok' diff --git a/oas/measurement.yaml b/oas/measurement.yaml index 52c0430..0b4d5b2 100644 --- a/oas/measurement.yaml +++ b/oas/measurement.yaml @@ -3,7 +3,7 @@ - $ref: 'oas.yaml#/components/parameters/Id' get: summary: TODO measurement values by id - description: 'levels: read, write, maintain, dev, admin' + description: 'Auth: all, levels: read, write, maintain, dev, admin' tags: - /measurement responses: @@ -23,9 +23,11 @@ $ref: 'oas.yaml#/components/responses/500' put: summary: TODO add/change measurement - description: 'levels: write, maintain, dev, admin' + description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /measurement + security: + - BasicAuth: [] requestBody: required: true content: @@ -51,9 +53,11 @@ $ref: 'oas.yaml#/components/responses/500' delete: summary: TODO delete measurement - description: 'levels: write, maintain, dev, admin' + description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /measurement + security: + - BasicAuth: [] responses: 200: $ref: 'oas.yaml#/components/responses/Ok' diff --git a/oas/model.yaml b/oas/model.yaml index ce237e2..24df9af 100644 --- a/oas/model.yaml +++ b/oas/model.yaml @@ -3,7 +3,7 @@ - $ref: 'oas.yaml#/components/parameters/Name' get: summary: TODO get model data by name - description: 'levels: dev, admin' + description: 'Auth: all, levels: dev, admin' tags: - /model responses: @@ -24,7 +24,7 @@ $ref: 'oas.yaml#/components/responses/500' put: summary: TODO add/replace model data by name - description: 'levels: dev, admin' + description: 'Auth: all, levels: dev, admin' tags: - /model requestBody: @@ -50,9 +50,11 @@ $ref: 'oas.yaml#/components/responses/500' delete: summary: TODO delete model data - description: 'levels: dev, admin' + description: 'Auth: basic, levels: dev, admin' tags: - /model + security: + - BasicAuth: [] responses: 200: $ref: 'oas.yaml#/components/responses/Ok' diff --git a/oas/oas.yaml b/oas/oas.yaml index ba1bafd..03549c1 100644 --- a/oas/oas.yaml +++ b/oas/oas.yaml @@ -6,7 +6,10 @@ info: version: 1.0.0 description: | This API gives access to the project database.
    - Access is restricted. Authentication can be obtained with HTTP Basic Auth using username and password. Data access methods can also be accessed using an API key at the URL ending like ?key=xxx
    + Access is restricted. Authentication can be obtained with HTTP Basic Auth using username and password. + Data access methods can also be accessed using an API key at the URL ending like ?key=xxx
    + The description lists available authentication methods, also the locks of each method close correspondingly + if the entered authentication is allowed.

    There are a number of different user levels:
    • read: read access to the samples database
    • diff --git a/oas/others.yaml b/oas/others.yaml index e5f200e..c543797 100644 --- a/oas/others.yaml +++ b/oas/others.yaml @@ -1,6 +1,7 @@ /: get: summary: Root method + description: 'Auth: none' tags: - / security: [] @@ -14,5 +15,29 @@ status: type: string example: 'API server up and running!' + 500: + $ref: 'oas.yaml#/components/responses/500' + +/authorized: + get: + summary: Checks authorization + description: 'Auth: all, levels: read, write, maintain, dev, admin' + tags: + - / + responses: + 200: + description: Authorized + content: + application/json: + schema: + properties: + status: + type: string + example: 'Authorization successful' + method: + type: string + example: 'basic' + 401: + $ref: 'oas.yaml#/components/responses/401' 500: $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/sample.yaml b/oas/sample.yaml index 8464e06..b84be19 100644 --- a/oas/sample.yaml +++ b/oas/sample.yaml @@ -1,7 +1,7 @@ /samples: get: summary: TODO all samples in overview - description: 'levels: read, write, maintain, dev, admin' + description: 'Auth: all, levels: read, write, maintain, dev, admin' tags: - /sample responses: @@ -20,7 +20,7 @@ - $ref: 'oas.yaml#/components/parameters/Id' get: summary: TODO sample details - description: 'levels: read, write, maintain, dev, admin' + description: 'Auth: all, levels: read, write, maintain, dev, admin' tags: - /sample responses: @@ -40,9 +40,11 @@ $ref: 'oas.yaml#/components/responses/500' put: summary: TODO add/change sample - description: 'levels: write, maintain, dev, admin' + description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /sample + security: + - BasicAuth: [] requestBody: required: true content: @@ -68,9 +70,11 @@ $ref: 'oas.yaml#/components/responses/500' delete: summary: TODO delete sample - description: 'levels: write, maintain, dev, admin' + description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /sample + security: + - BasicAuth: [] responses: 200: $ref: 'oas.yaml#/components/responses/Ok' @@ -87,7 +91,7 @@ /sample/notes/fields: get: summary: TODO list all existing field names for custom notes fields - description: 'levels: write, maintain, dev, admin' + description: 'Auth: all, levels: write, maintain, dev, admin' tags: - /sample responses: diff --git a/oas/template.yaml b/oas/template.yaml index bce58d0..a09cb21 100644 --- a/oas/template.yaml +++ b/oas/template.yaml @@ -1,7 +1,7 @@ /template/treatments: get: summary: TODO all available treatment methods - description: 'levels: read, write, maintain, dev, admin' + description: 'Auth: basic, levels: read, write, maintain, dev, admin' tags: - /templates security: @@ -30,7 +30,7 @@ - $ref: 'oas.yaml#/components/parameters/Name' get: summary: TODO treatment method details - description: 'levels: read, write, maintain, admin' + description: 'Auth: basic, levels: read, write, maintain, admin' tags: - /templates security: @@ -59,9 +59,11 @@ $ref: 'oas.yaml#/components/responses/500' put: summary: TODO add/change treatment method - description: 'levels: maintain, admin' + description: 'Auth: basic, levels: maintain, admin' tags: - /templates + security: + - BasicAuth: [] requestBody: required: true content: @@ -101,9 +103,11 @@ $ref: 'oas.yaml#/components/responses/500' delete: summary: TODO delete treatment method - description: 'levels: maintain, admin' + description: 'Auth: basic, levels: maintain, admin' tags: - /templates + security: + - BasicAuth: [] responses: 200: $ref: 'oas.yaml#/components/responses/Ok' @@ -120,7 +124,7 @@ /template/measurements: get: summary: TODO all available measurement methods - description: 'levels: read, write, maintain, dev, admin' + description: 'Auth: basic, levels: read, write, maintain, dev, admin' tags: - /templates security: @@ -150,7 +154,7 @@ - $ref: 'oas.yaml#/components/parameters/Name' get: summary: TODO measurement method details - description: 'levels: read, write, maintain, admin' + description: 'Auth: basic, levels: read, write, maintain, admin' tags: - /templates security: @@ -180,9 +184,11 @@ $ref: 'oas.yaml#/components/responses/500' put: summary: TODO add/change measurement method - description: 'levels: maintain, admin' + description: 'Auth: basic, levels: maintain, admin' tags: - /templates + security: + - BasicAuth: [] requestBody: required: true content: @@ -224,9 +230,11 @@ $ref: 'oas.yaml#/components/responses/500' delete: summary: TODO delete measurement method - description: 'levels: maintain, admin' + description: 'Auth: basic, levels: maintain, admin' tags: - /templates + security: + - BasicAuth: [] responses: 200: $ref: 'oas.yaml#/components/responses/Ok' diff --git a/oas/user.yaml b/oas/user.yaml index f8434f5..3db2b3c 100644 --- a/oas/user.yaml +++ b/oas/user.yaml @@ -1,7 +1,7 @@ /users: get: summary: TODO lists all users - description: 'levels: admin' + description: 'Auth: basic, levels: admin' tags: - /user security: @@ -26,7 +26,7 @@ - $ref: 'oas.yaml#/components/parameters/Name' get: summary: TODO list user details - description: 'levels: read, write, maintain, dev get their own information without a name property specified, level: admin can get any user using the name parameter' + description: 'Auth: basic, levels: read, write, maintain, dev get their own information without a name property specified, level: admin can get any user using the name parameter' tags: - /user security: @@ -52,9 +52,11 @@ $ref: 'oas.yaml#/components/responses/500' put: summary: TODO change user details - description: 'levels: read, write, maintain, dev can change their own information (except level) without a name property specified, level: admin can change any user using the name parameter' + description: 'Auth: basic, levels: read, write, maintain, dev can change their own information (except level) without a name property specified, level: admin can change any user using the name parameter' tags: - /user + security: + - BasicAuth: [] requestBody: required: true content: @@ -82,9 +84,11 @@ $ref: 'oas.yaml#/components/responses/500' delete: summary: TODO delete user - description: 'levels: read, write, maintain, dev can delete their own account, level: admin can delete any user using the name parameter' + description: 'Auth: basic, levels: read, write, maintain, dev can delete their own account, level: admin can delete any user using the name parameter' tags: - /user + security: + - BasicAuth: [] responses: 200: $ref: 'oas.yaml#/components/responses/Ok' @@ -101,7 +105,7 @@ /user/key: get: summary: TODO get API key for the user - description: 'levels: read, write, maintain, dev, admin' + description: 'Auth: basic, levels: read, write, maintain, dev, admin' tags: - /user security: @@ -120,7 +124,7 @@ /user/new: post: summary: TODO add new user - description: 'levels: admin' + description: 'Auth: basic, levels: admin' tags: - /user security: @@ -157,6 +161,7 @@ /user/passreset: post: summary: TODO reset password and send mail to restore + description: 'Auth: none' tags: - /user security: [] diff --git a/package-lock.json b/package-lock.json index 956d6f9..795376c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -208,6 +208,14 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, + "basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "requires": { + "safe-buffer": "5.1.2" + } + }, "bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -562,6 +570,11 @@ "safe-buffer": "5.1.2" } }, + "content-filter": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/content-filter/-/content-filter-1.1.2.tgz", + "integrity": "sha512-VaZ4Y7h776r0v2WxWqu3iatjYI6/N0msXK8O1ymtkFWbSvaFoCePksS8U60BS6dUMZeAlqhN09SuM7ghdzRP1Q==" + }, "content-type": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", @@ -1440,6 +1453,11 @@ } } }, + "mongo-sanitize": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mongo-sanitize/-/mongo-sanitize-1.1.0.tgz", + "integrity": "sha512-6gB9AiJD+om2eZLxaPKIP5Q8P3Fr+s+17rVWso7hU0+MAzmIvIMlgTYuyvalDLTtE/p0gczcvJ8A3pbN1XmQ/A==" + }, "mongodb": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.4.1.tgz", diff --git a/package.json b/package.json index 234bb65..7630a0c 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "API for the digital fingerprint of plastics mongodb", "main": "index.js", "scripts": { + "tsc": "tsc", "test": "mocha dist/**/**.spec.js", "start": "tsc && node dist/index.js", "dev": "nodemon -e ts,yaml --exec \"npm run start\"" @@ -16,11 +17,14 @@ "@hapi/joi": "^17.1.1", "@types/mocha": "^5.2.7", "@types/node": "^13.1.6", + "basic-auth": "^2.0.1", "bcryptjs": "^2.4.3", "body-parser": "^1.19.0", "cfenv": "^1.2.2", + "content-filter": "^1.1.2", "express": "^4.17.1", "json-schema": "^0.2.5", + "mongo-sanitize": "^1.1.0", "mongoose": "^5.8.7", "nodemon": "^2.0.3", "swagger-ui-express": "^4.1.2", diff --git a/src/db.ts b/src/db.ts index 98c8617..de45a74 100644 --- a/src/db.ts +++ b/src/db.ts @@ -39,6 +39,17 @@ export default class db { if (err) done(err); }); mongoose.connection.on('error', console.error.bind(console, 'connection error:')); + mongoose.connection.on('disconnected', () => { // reset state on disconnect + console.log('Database disconnected'); + this.state.db = 0; + done(); + }); + process.on('SIGINT', () => { // close connection when app is terminated + mongoose.connection.close(() => { + console.log('Mongoose default connection disconnected through app termination'); + process.exit(0); + }); + }); mongoose.connection.once('open', () => { console.log(process.env.NODE_ENV === 'test' ? '' : `Connected to ${connectionString}`); this.state.db = mongoose.connection; diff --git a/src/globals.ts b/src/globals.ts index e6db442..0d4ccdb 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -1,5 +1,5 @@ const globals = { - levels: [ + levels: [ // access levels 'read', 'write', 'maintain', diff --git a/src/helpers/authorize.ts b/src/helpers/authorize.ts new file mode 100644 index 0000000..e42f388 --- /dev/null +++ b/src/helpers/authorize.ts @@ -0,0 +1,100 @@ +import basicAuth from 'basic-auth'; +import bcrypt from 'bcryptjs'; +import UserModel from '../models/user'; + + +// appends req.auth(res, ['levels'], method = 'all') +// which returns sends error message and returns false if unauthorized, otherwise true +// req.authDetails returns eg. {methods: ['basic'], username: 'johndoe', level: 'write'} + +module.exports = async (req, res, next) => { + let givenMethod = ''; // authorization method given by client, basic taken preferred + let user = {name: '', level: ''}; // user object + + // test authentications + const userBasic = await basic(req, next); + + if (userBasic) { // basic available + givenMethod = 'basic'; + user = userBasic; + } + else { // if basic not available, test key + const userKey = await key(req, next); + if (userKey) { + givenMethod = 'key'; + user = userKey; + } + } + + req.auth = (res, levels, method = 'all') => { + if (givenMethod === method || (method === 'all' && givenMethod !== '')) { // method is available + if (levels.indexOf(user.level) > -1) { // level is available + return true; + } + else { + res.status(403).json({status: 'Forbidden'}); + return false; + } + } + else { + res.status(401).json({status: 'Unauthorized'}); + return false; + } + } + + req.authDetails = { + method: givenMethod, + username: user.name, + level: user.level + }; + + next(); +} + + +function basic (req, next): any { // checks basic auth and returns changed user object + return new Promise(resolve => { + const auth = basicAuth(req); + if (auth !== undefined) { // basic auth available + UserModel.find({name: auth.name}).lean().exec( 'find', (err, data) => { // find user + if (err) next(err); + if (data.length === 1) { // one user found + bcrypt.compare(auth.pass, data[0].pass, (err, res) => { // check password + if (err) next(err); + if (res === true) { + resolve({level: data[0].level, name: data[0].name}); + } + else { + resolve(null); + } + }); + } + else { + resolve(null); + } + }); + } + else { + resolve(null); + } + }); +} + +function key (req, next): any { // checks API key and returns changed user object + return new Promise(resolve => { + if (req.query.key !== undefined) { + UserModel.find({key: req.query.key}).lean().exec( 'find', (err, data) => { // find user + if (err) next(err); + if (data.length === 1) { // one user found + resolve({level: data[0].level, name: data[0].name}); + } + else { + resolve(null); + } + }); + } + else { + resolve(null); + } + }); +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index cfaf696..67e29e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ import express from 'express'; import bodyParser from 'body-parser'; import swagger from 'swagger-ui-express'; import jsonRefParser, {JSONSchema} from '@apidevtools/json-schema-ref-parser'; +import contentFilter from 'content-filter'; +import mongoSanitize from 'mongo-sanitize'; import db from './db'; @@ -23,9 +25,23 @@ const port = process.env.PORT || 3000; app.use(express.json({ limit: '5mb'})); app.use(express.urlencoded({ extended: false, limit: '5mb' })); app.use(bodyParser.json()); +app.use(contentFilter()); // filter URL query attacks +app.use((req, res, next) => { // filter body query attacks + req.body = mongoSanitize(req.body); + next(); +}); app.use((err, req, res, ignore) => { // bodyParser error handling res.status(400).send({status: 'Invalid JSON body'}); }); +app.use((req, res, next) => { // no database connection error + if (db.getState().db) { + next(); + } + else { + res.status(500).send({status: 'Internal server error'}); + } +}); +app.use(require('./helpers/authorize')); // handle authentication // require routes app.use('/', require('./routes/root')); diff --git a/src/routes/root.spec.ts b/src/routes/root.spec.ts index 276f159..61544a8 100644 --- a/src/routes/root.spec.ts +++ b/src/routes/root.spec.ts @@ -26,14 +26,16 @@ describe('/', () => { supertest(server) .get('/') .expect('Content-type', /json/) - .expect(200, (err, res) => { + .expect(200) + .end((err, res) => { + if (err) done (err); should(res.body).be.eql({status: 'API server up and running!'}); done(); }); }); }); -describe('Testing unknown routes', () => { +describe('Unknown routes', () => { let server; before(done => { @@ -50,10 +52,94 @@ describe('Testing unknown routes', () => { afterEach(done => { server.close(done); }); - it('returns a 404 message', done => { + it('return a 404 message', done => { supertest(server) .get('/unknownroute') - .expect(404); - done(); + .expect(404) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Not found'}); + done(); + }); + }); +}); + +describe('An unauthorized request', () => { + 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 401 message', done => { + supertest(server) + .get('/authorized') + .expect(401) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Unauthorized'}); + done(); + }); + }); + it('does not work with correct username', done => { + supertest(server) + .get('/authorized') + .auth('admin', 'Abc123!!') + .expect(401) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Unauthorized'}); + done(); + }); + }); +}); + +describe('An authorized request', () => { + 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('works with an API key', done => { + supertest(server) + .get('/authorized?key=5ea131671feb9c2ee0aafc9a') + .expect(200) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Authorization successful', method: 'key'}); + done(); + }); + }); + it('works with basic auth', done => { + supertest(server) + .get('/authorized') + .auth('admin', 'Abc123!#') + .expect(200) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Authorization successful', method: 'basic'}); + done(); + }); }); }); \ No newline at end of file diff --git a/src/routes/root.ts b/src/routes/root.ts index bcbb40b..2705280 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -1,4 +1,5 @@ import express from 'express'; +import globals from '../globals'; const router = express.Router(); @@ -6,4 +7,9 @@ router.get('/', (req, res) => { res.json({status: 'API server up and running!'}); }); +router.get('/authorized', (req, res) => { + if (!req.auth(res, globals.levels)) return; + res.json({status: 'Authorization successful', method: req.authDetails.method}); +}); + module.exports = router; diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index 4c50a70..f78d387 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -26,10 +26,12 @@ describe('/user/new', () => { it('returns the added user data', done => { supertest(server) .post('/user/new') + .auth('admin', 'Abc123!#') .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); + .expect(200) + .end((err, res) => { + if (err) 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'); @@ -42,9 +44,11 @@ describe('/user/new', () => { it('stores the data', done => { supertest(server) .post('/user/new') + .auth('admin', 'Abc123!#') .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); + .expect(200) + .end(err => { + if (err) done (err); UserModel.find({name: 'johndoe'}).lean().exec( 'find', (err, data) => { if (err) return done(err); should(data).have.lengthOf(1); @@ -63,9 +67,11 @@ describe('/user/new', () => { it('rejects a username already in use', done => { supertest(server) .post('/user/new') + .auth('admin', 'Abc123!#') .send({email: 'j.doe@bosch.com', name: 'janedoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) - .expect(400, (err, res) => { - if (err) return done(err); + .expect(400) + .end((err, res) => { + if (err) done (err); should(res.body).be.eql({status: 'Username already taken'}); UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { if (err) return done(err); @@ -73,5 +79,30 @@ describe('/user/new', () => { done(); }); }); - }); // TODO: authentication + }); + it('rejects requests from non-admins', done => { + supertest(server) + .post('/user/new') + .auth('janedoe', 'Abc123!#') + .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) + .expect('Content-type', /json/) + .expect(403) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Forbidden'}); + done(); + }); + }); + it('rejects requests from an admin API key', done => { + supertest(server) + .post('/user/new?key=5ea131671feb9c2ee0aafc9a') + .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) + .expect('Content-type', /json/) + .expect(401) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Unauthorized'}); + done(); + }); + }); }); \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts index 4c1d8ed..ffaebc7 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -11,6 +11,9 @@ router.get('/users', (req, res) => { }); router.post('/user/new', (req, res, next) => { + console.log(req.authDetails); + if (!req.auth(res, ['admin'], 'basic')) return; + // validate input const {error, value: user} = UserValidate.input(req.body); if(error !== undefined) { diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index 8f658f3..1cccf41 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -1,5 +1,5 @@ import joi from '@hapi/joi'; -import globals from "../../globals"; +import globals from '../../globals'; export default class UserValidate { // validate input for user static input (data) { @@ -27,6 +27,7 @@ export default class UserValidate { // validate input for user .required(), device_name: joi.string() + .allow('') .required() }).validate(data); } diff --git a/src/test/db.json b/src/test/db.json index ba55d6f..7e32395 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -5,12 +5,23 @@ "_id": "5ea0450ed851c30a90e70894", "email": "jane.doe@bosch.com", "name": "janedoe", - "pass": "$2a$10$KDKZjCsgDXwhtKdXZ9oG2ueDuCZsRKOMSqHuBfCM/2R0V6DRns.sy", + "pass": "$2a$10$i872o3qR5V3JnbDArD8Z.eDo.BNPDBaR7dUX9KSEtl9pUjLyucy2K", "level": "write", "location": "Rng", "device_name": "Alpha I", "key": "5ea0450ed851c30a90e70899", "__v": 0 + }, + { + "_id": "5ea131671feb9c2ee0aafc9b", + "email": "a.d.m.i.n@bosch.com", + "name": "admin", + "pass": "$2a$10$i872o3qR5V3JnbDArD8Z.eDo.BNPDBaR7dUX9KSEtl9pUjLyucy2K", + "level": "admin", + "location": "Rng", + "device_name": "", + "key": "5ea131671feb9c2ee0aafc9a", + "__v": "0" } ] } From 4e68267bfd361dd5c39d30bd87d56f94c50828c5 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 23 Apr 2020 17:46:00 +0200 Subject: [PATCH 04/83] added passreset and mail helper --- oas/schemas.yaml | 11 +++-- oas/user.yaml | 8 ++-- package-lock.json | 26 ++++++++++++ package.json | 1 + src/db.ts | 1 + src/helpers/mail.ts | 64 ++++++++++++++++++++++++++++ src/routes/user.spec.ts | 92 +++++++++++++++++++++++++++++++++++++++++ src/routes/user.ts | 25 ++++++++++- 8 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 src/helpers/mail.ts diff --git a/oas/schemas.yaml b/oas/schemas.yaml index 82c9508..d4151d1 100644 --- a/oas/schemas.yaml +++ b/oas/schemas.yaml @@ -141,14 +141,17 @@ Email: email: type: string example: john.doe@bosch.com -User: - allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - - $ref: 'oas.yaml#/components/schemas/Email' +UserName: properties: name: type: string example: johndoe +User: + allOf: + - $ref: 'oas.yaml#/components/schemas/_Id' + - $ref: 'oas.yaml#/components/schemas/UserName' + - $ref: 'oas.yaml#/components/schemas/Email' + properties: pass: type: string writeOnly: true diff --git a/oas/user.yaml b/oas/user.yaml index 3db2b3c..4042901 100644 --- a/oas/user.yaml +++ b/oas/user.yaml @@ -171,11 +171,13 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/Email' + allOf: + - $ref: 'oas.yaml#/components/schemas/UserName' + - $ref: 'oas.yaml#/components/schemas/Email' responses: 200: $ref: 'oas.yaml#/components/responses/Ok' - 401: - $ref: 'oas.yaml#/components/responses/401' + 404: + $ref: 'oas.yaml#/components/responses/404' 500: $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 795376c..b8354bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -203,6 +203,14 @@ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", "dev": true }, + "axios": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "requires": { + "follow-redirects": "1.5.10" + } + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -848,6 +856,24 @@ "is-buffer": "~2.0.3" } }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "requires": { + "debug": "=3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, "form-data": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", diff --git a/package.json b/package.json index 7630a0c..d5a2bfe 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@hapi/joi": "^17.1.1", "@types/mocha": "^5.2.7", "@types/node": "^13.1.6", + "axios": "^0.19.2", "basic-auth": "^2.0.1", "bcryptjs": "^2.4.3", "body-parser": "^1.19.0", diff --git a/src/db.ts b/src/db.ts index de45a74..b93fb6f 100644 --- a/src/db.ts +++ b/src/db.ts @@ -51,6 +51,7 @@ export default class db { }); }); mongoose.connection.once('open', () => { + mongoose.set('useFindAndModify', false); console.log(process.env.NODE_ENV === 'test' ? '' : `Connected to ${connectionString}`); this.state.db = mongoose.connection; done(); diff --git a/src/helpers/mail.ts b/src/helpers/mail.ts new file mode 100644 index 0000000..949d243 --- /dev/null +++ b/src/helpers/mail.ts @@ -0,0 +1,64 @@ +import axios from 'axios'; + +// sends an email + +export default (mailAddress, subject, content, f) => { // callback, executed empty or with error + if (process.env.NODE_ENV === 'production') { + const mailService = JSON.parse(process.env.VCAP_SERVICES).Mail[0]; + axios({ + method: 'post', + url: mailService.credentials.uri + '/email', + auth: {username: mailService.credentials.username, password: mailService.credentials.password}, + data: { + recipients: [{to: mailAddress}], + subject: {content: subject}, + body: { + content: content, + contentType: "text/html" + }, + from: { + eMail: "dfop@bosch-iot.com", + password: "PlasticsOfFingerprintDigital" + } + } + }) + .then(() => { + f(); + }) + .catch((err) => { + f(err); + }); + } + else if (process.env.NODE_ENV === 'test') { + console.log('Sending mail to ' + mailAddress + ': -- ' + subject + ' -- ' + content); + f(); + } + else { // dev + axios({ + method: 'get', + url: 'https://digital-fingerprint-of-plastics-mail-test.apps.de1.bosch-iot-cloud.com/api', + data: { + method: 'post', + url: '/email', + data: { + recipients: [{to: mailAddress}], + subject: {content: subject}, + body: { + content: content, + contentType: "text/html" + }, + from: { + eMail: "dfop-test@bosch-iot.com", + password: "PlasticsOfFingerprintDigital" + } + } + } + }) + .then(() => { + f(); + }) + .catch((err) => { + f(err); + }); + } +} \ No newline at end of file diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index f78d387..a6ebec0 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -105,4 +105,96 @@ describe('/user/new', () => { done(); }); }); +}); + + + + +describe('/user/passreset', () => { + let server; + + before(done => { + process.env.port = '2999'; + process.env.NODE_ENV = 'test'; + 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 ok response', done => { + supertest(server) + .post('/user/passreset') + .send({ + email: 'jane.doe@bosch.com', + name: 'janedoe' + }) + .expect('Content-type', /json/) + .expect(200) + .end((err, res) => { + if (err) done(err); + should(res.body).be.eql({status: 'OK'}); + done(); + }); + }); + it('returns 404 for wrong username/email combo', done => { + supertest(server) + .post('/user/passreset') + .send({ + email: 'jane.doe@bosch.com', + name: 'admin' + }) + .expect('Content-type', /json/) + .expect(404) + .end((err, res) => { + if (err) done(err); + should(res.body).be.eql({status: 'Not found'}); + done(); + }); + }); + it('returns 404 for unknown username', done => { + supertest(server) + .post('/user/passreset') + .send({ + email: 'jane.doe@bosch.com', + name: 'admin' + }) + .expect('Content-type', /json/) + .expect(404) + .end((err, res) => { + if (err) done(err); + should(res.body).be.eql({status: 'Not found'}); + done(); + }); + }); + it('changes the user password', done => { + UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + if (err) return done(err); + const oldpass = data[0].pass; + supertest(server) + .post('/user/passreset') + .send({ + email: 'jane.doe@bosch.com', + name: 'janedoe' + }) + .expect('Content-type', /json/) + .expect(200) + .end((err, res) => { + if (err) done(err); + should(res.body).be.eql({status: 'OK'}); + UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + if (err) return done(err); + should(data[0].pass).not.eql(oldpass); + done(); + }); + }); + }); + }); }); \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts index ffaebc7..e4a17d5 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -3,6 +3,7 @@ import mongoose from 'mongoose'; import bcrypt from 'bcryptjs'; import UserValidate from './validate/user'; import UserModel from '../models/user'; +import mail from '../helpers/mail'; const router = express.Router(); @@ -11,7 +12,6 @@ router.get('/users', (req, res) => { }); router.post('/user/new', (req, res, next) => { - console.log(req.authDetails); if (!req.auth(res, ['admin'], 'basic')) return; // validate input @@ -40,4 +40,27 @@ router.post('/user/new', (req, res, next) => { }); }); +router.post('/user/passreset', (req, res, next) => { + // check if user/email combo exists + UserModel.find({name: req.body.name, email: req.body.email}).lean().exec( 'find', (err, data) => { + if (err) next(err); + if (data.length === 1) { // it exists + const newPass = Math.random().toString(36).substring(2); + bcrypt.hash(newPass, 10, (err, hash) => { // password hashing + if (err) next(err); + UserModel.findOneAndUpdate({name: req.body.name, email: req.body.email}, {pass: hash}, err => { + if (err) next(err); + mail(data[0].email, 'Your new password for the DFOP database', 'Hi,

      You requested to reset your password.
      Your new password is:

      ' + newPass + '

      If you did not request a password reset, talk to the sysadmin quickly!

      Have a nice day.

      The DFOP team', err => { + if (err) next(err); + res.json({status: 'OK'}); + }); + }); + }); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + module.exports = router; \ No newline at end of file From 8bf408138f73cbae64eef5b6028a76376a35a20d Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 24 Apr 2020 10:53:45 +0200 Subject: [PATCH 05/83] changed to findById and improved db.loadJson --- package-lock.json | 31 +++++++++++++++++++++++++++++++ package.json | 2 ++ src/db.ts | 6 ++++++ src/helpers/authorize.ts | 4 ++-- src/routes/user.spec.ts | 5 +++-- src/routes/user.ts | 4 ++-- src/test/db.json | 4 ++-- 7 files changed, 48 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index b8354bc..5249707 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,6 +93,19 @@ "defer-to-connect": "^1.0.1" } }, + "@types/bcrypt": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-3.0.0.tgz", + "integrity": "sha512-nohgNyv+1ViVcubKBh0+XiNJ3dO8nYu///9aJ4cgSqv70gBL+94SNy/iC2NLzKPT2Zt/QavrOkBVbZRLZmw6NQ==" + }, + "@types/bson": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.2.tgz", + "integrity": "sha512-+uWmsejEHfmSjyyM/LkrP0orfE2m5Mx9Xel4tXNeqi1ldK5XMQcDsFkBmLDtuyKUbxj2jGDo0H240fbCRJZo7Q==", + "requires": { + "@types/node": "*" + } + }, "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", @@ -103,6 +116,24 @@ "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==" }, + "@types/mongodb": { + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.5.10.tgz", + "integrity": "sha512-6NkJNfFdFa/njBvN/9eAfq78bWUnapkdR3JbWGGpd7U71PjgKweA4Tlag8psi2mqm973vBYVTD1oc1u0lzRcig==", + "requires": { + "@types/bson": "*", + "@types/node": "*" + } + }, + "@types/mongoose": { + "version": "5.7.12", + "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.7.12.tgz", + "integrity": "sha512-yzLJk3cdSwuMXaIacUCWUb8m960YcgnID7S4ZPOOgzT39aSC46670TuunN+ajDio7OUcGG4mGg8eOGs2Z6VmrA==", + "requires": { + "@types/mongodb": "*", + "@types/node": "*" + } + }, "@types/node": { "version": "13.1.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.6.tgz", diff --git a/package.json b/package.json index d5a2bfe..adc9874 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "dependencies": { "@apidevtools/json-schema-ref-parser": "^8.0.0", "@hapi/joi": "^17.1.1", + "@types/bcrypt": "^3.0.0", "@types/mocha": "^5.2.7", + "@types/mongoose": "^5.7.12", "@types/node": "^13.1.6", "axios": "^0.19.2", "basic-auth": "^2.0.1", diff --git a/src/db.ts b/src/db.ts index b93fb6f..00477da 100644 --- a/src/db.ts +++ b/src/db.ts @@ -88,6 +88,12 @@ export default class db { let loadCounter = 0; // count number of loaded collections to know when to return done() Object.keys(json.collections).forEach(collectionName => { // create each collection + for(let i in json.collections[collectionName]) { // convert $oid fields to actual ObjectIds + console.log(json.collections[collectionName][i]); + Object.keys(json.collections[collectionName][i]).forEach(key => { + json.collections[collectionName][i][key] = json.collections[collectionName][i][key].hasOwnProperty('$oid') ? mongoose.Types.ObjectId(json.collections[collectionName][i][key].$oid) : json.collections[collectionName][i][key]; + }) + } this.state.db.createCollection(collectionName, (err, collection) => { collection.insertMany(json.collections[collectionName], () => { // insert JSON data if (++ loadCounter >= Object.keys(json.collections).length) { // all collections loaded diff --git a/src/helpers/authorize.ts b/src/helpers/authorize.ts index e42f388..d3c7e75 100644 --- a/src/helpers/authorize.ts +++ b/src/helpers/authorize.ts @@ -56,7 +56,7 @@ function basic (req, next): any { // checks basic auth and returns changed user return new Promise(resolve => { const auth = basicAuth(req); if (auth !== undefined) { // basic auth available - UserModel.find({name: auth.name}).lean().exec( 'find', (err, data) => { // find user + UserModel.find({name: auth.name}).lean().exec( (err, data: any) => { // find user if (err) next(err); if (data.length === 1) { // one user found bcrypt.compare(auth.pass, data[0].pass, (err, res) => { // check password @@ -83,7 +83,7 @@ function basic (req, next): any { // checks basic auth and returns changed user function key (req, next): any { // checks API key and returns changed user object return new Promise(resolve => { if (req.query.key !== undefined) { - UserModel.find({key: req.query.key}).lean().exec( 'find', (err, data) => { // find user + UserModel.find({key: req.query.key}).lean().exec( (err, data: any) => { // find user if (err) next(err); if (data.length === 1) { // one user found resolve({level: data[0].level, name: data[0].name}); diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index a6ebec0..c4511ec 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -175,7 +175,7 @@ describe('/user/passreset', () => { }); }); it('changes the user password', done => { - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data: any) => { if (err) return done(err); const oldpass = data[0].pass; supertest(server) @@ -189,8 +189,9 @@ describe('/user/passreset', () => { .end((err, res) => { if (err) done(err); should(res.body).be.eql({status: 'OK'}); - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + UserModel.find({name: 'janedoe'}).lean().exec( (err, data: any) => { if (err) return done(err); + console.log(data); should(data[0].pass).not.eql(oldpass); done(); }); diff --git a/src/routes/user.ts b/src/routes/user.ts index e4a17d5..cd67d14 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -42,13 +42,13 @@ router.post('/user/new', (req, res, next) => { router.post('/user/passreset', (req, res, next) => { // check if user/email combo exists - UserModel.find({name: req.body.name, email: req.body.email}).lean().exec( 'find', (err, data) => { + UserModel.find({name: req.body.name, email: req.body.email}).lean().exec( (err, data: any) => { if (err) next(err); if (data.length === 1) { // it exists const newPass = Math.random().toString(36).substring(2); bcrypt.hash(newPass, 10, (err, hash) => { // password hashing if (err) next(err); - UserModel.findOneAndUpdate({name: req.body.name, email: req.body.email}, {pass: hash}, err => { + UserModel.findByIdAndUpdate(data[0]._id, {pass: hash}, err => { // write new password if (err) next(err); mail(data[0].email, 'Your new password for the DFOP database', 'Hi,

      You requested to reset your password.
      Your new password is:

      ' + newPass + '

      If you did not request a password reset, talk to the sysadmin quickly!

      Have a nice day.

      The DFOP team', err => { if (err) next(err); diff --git a/src/test/db.json b/src/test/db.json index 7e32395..af2d78f 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -2,7 +2,7 @@ "collections": { "users": [ { - "_id": "5ea0450ed851c30a90e70894", + "_id": {"$oid":"5ea0450ed851c30a90e70894"}, "email": "jane.doe@bosch.com", "name": "janedoe", "pass": "$2a$10$i872o3qR5V3JnbDArD8Z.eDo.BNPDBaR7dUX9KSEtl9pUjLyucy2K", @@ -13,7 +13,7 @@ "__v": 0 }, { - "_id": "5ea131671feb9c2ee0aafc9b", + "_id": {"$oid":"5ea131671feb9c2ee0aafc9b"}, "email": "a.d.m.i.n@bosch.com", "name": "admin", "pass": "$2a$10$i872o3qR5V3JnbDArD8Z.eDo.BNPDBaR7dUX9KSEtl9pUjLyucy2K", From a64229d1dc605360d489eedf77e413bce1160b90 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 24 Apr 2020 12:25:32 +0200 Subject: [PATCH 06/83] added GET /user route --- oas/schemas.yaml | 1 + oas/user.yaml | 117 +++++++++++++--- src/db.ts | 3 +- src/models/user.ts | 2 +- src/routes/root.spec.ts | 2 +- src/routes/user.spec.ts | 263 +++++++++++++++++++++++++++++++++++- src/routes/user.ts | 21 ++- src/routes/validate/user.ts | 7 +- src/test/db.json | 2 +- 9 files changed, 385 insertions(+), 33 deletions(-) diff --git a/oas/schemas.yaml b/oas/schemas.yaml index d4151d1..4d8a805 100644 --- a/oas/schemas.yaml +++ b/oas/schemas.yaml @@ -1,5 +1,6 @@ Id: type: string + example: 5ea0450ed851c30a90e70894 _Id: properties: _id: diff --git a/oas/user.yaml b/oas/user.yaml index 4042901..763a051 100644 --- a/oas/user.yaml +++ b/oas/user.yaml @@ -1,6 +1,6 @@ /users: get: - summary: TODO lists all users + summary: lists all users description: 'Auth: basic, levels: admin' tags: - /user @@ -21,12 +21,10 @@ $ref: 'oas.yaml#/components/responses/403' 500: $ref: 'oas.yaml#/components/responses/500' -/user/{name}: - parameters: - - $ref: 'oas.yaml#/components/parameters/Name' +/user: get: - summary: TODO list user details - description: 'Auth: basic, levels: read, write, maintain, dev get their own information without a name property specified, level: admin can get any user using the name parameter' + summary: list own user details + description: 'Auth: basic, levels: read, write, maintain, admin' tags: - /user security: @@ -37,9 +35,7 @@ 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: @@ -52,7 +48,98 @@ $ref: 'oas.yaml#/components/responses/500' put: summary: TODO change user details - description: 'Auth: basic, levels: read, write, maintain, dev can change their own information (except level) without a name property specified, level: admin can change any user using the name parameter' + description: 'Auth: basic, levels: read, write, maintain, admin' + tags: + - /user + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: 'oas.yaml#/components/schemas/_Id' + - $ref: 'oas.yaml#/components/schemas/UserName' + - $ref: 'oas.yaml#/components/schemas/Email' + properties: + pass: + type: string + writeOnly: true + example: Abc123!# + location: + type: string + example: Rng + device_name: + type: string + example: Alpha II + responses: + 200: + description: user details + content: + application/json: + schema: + $ref: 'oas.yaml#/components/schemas/User' + 400: + $ref: 'oas.yaml#/components/responses/400' + 401: + $ref: 'oas.yaml#/components/responses/401' + 403: + $ref: 'oas.yaml#/components/responses/403' + 404: + $ref: 'oas.yaml#/components/responses/404' + 500: + $ref: 'oas.yaml#/components/responses/500' + delete: + summary: TODO delete user + description: 'Auth: basic, levels: read, write, maintain, admin' + tags: + - /user + security: + - BasicAuth: [] + responses: + 200: + $ref: 'oas.yaml#/components/responses/Ok' + 400: + $ref: 'oas.yaml#/components/responses/400' + 401: + $ref: 'oas.yaml#/components/responses/401' + 403: + $ref: 'oas.yaml#/components/responses/403' + 404: + $ref: 'oas.yaml#/components/responses/404' + 500: + $ref: 'oas.yaml#/components/responses/500' +/user/{name}: + parameters: + - $ref: 'oas.yaml#/components/parameters/Name' + get: + summary: list user details + description: 'Auth: basic, levels: admin' + tags: + - /user + security: + - BasicAuth: [] + responses: + 200: + description: user details + content: + application/json: + schema: + $ref: 'oas.yaml#/components/schemas/User' + 400: + $ref: 'oas.yaml#/components/responses/400' + 401: + $ref: 'oas.yaml#/components/responses/401' + 403: + $ref: 'oas.yaml#/components/responses/403' + 404: + $ref: 'oas.yaml#/components/responses/404' + 500: + $ref: 'oas.yaml#/components/responses/500' + put: + summary: TODO change user details + description: 'Auth: basic, levels: admin' tags: - /user security: @@ -69,9 +156,7 @@ 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: @@ -84,7 +169,7 @@ $ref: 'oas.yaml#/components/responses/500' delete: summary: TODO delete user - description: 'Auth: basic, levels: read, write, maintain, dev can delete their own account, level: admin can delete any user using the name parameter' + description: 'Auth: basic, levels: admin' tags: - /user security: @@ -123,7 +208,7 @@ $ref: 'oas.yaml#/components/responses/500' /user/new: post: - summary: TODO add new user + summary: add new user description: 'Auth: basic, levels: admin' tags: - /user @@ -160,7 +245,7 @@ $ref: 'oas.yaml#/components/responses/500' /user/passreset: post: - summary: TODO reset password and send mail to restore + summary: reset password and send mail to restore description: 'Auth: none' tags: - /user diff --git a/src/db.ts b/src/db.ts index 00477da..e624696 100644 --- a/src/db.ts +++ b/src/db.ts @@ -35,7 +35,7 @@ export default class db { } // connect to db - mongoose.connect(connectionString, {useNewUrlParser: true, useUnifiedTopology: true}, err => { + mongoose.connect(connectionString, {useNewUrlParser: true, useUnifiedTopology: true, useCreateIndex: true}, err => { if (err) done(err); }); mongoose.connection.on('error', console.error.bind(console, 'connection error:')); @@ -89,7 +89,6 @@ export default class db { let loadCounter = 0; // count number of loaded collections to know when to return done() Object.keys(json.collections).forEach(collectionName => { // create each collection for(let i in json.collections[collectionName]) { // convert $oid fields to actual ObjectIds - console.log(json.collections[collectionName][i]); Object.keys(json.collections[collectionName][i]).forEach(key => { json.collections[collectionName][i][key] = json.collections[collectionName][i][key].hasOwnProperty('$oid') ? mongoose.Types.ObjectId(json.collections[collectionName][i][key].$oid) : json.collections[collectionName][i][key]; }) diff --git a/src/models/user.ts b/src/models/user.ts index b72dabb..50178a6 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -1,7 +1,7 @@ import mongoose from 'mongoose'; const UserSchema = new mongoose.Schema({ - name: String, + name: {type: String, index: {unique: true}}, email: String, pass: String, key: String, diff --git a/src/routes/root.spec.ts b/src/routes/root.spec.ts index 61544a8..9372259 100644 --- a/src/routes/root.spec.ts +++ b/src/routes/root.spec.ts @@ -3,7 +3,7 @@ import should from 'should/as-function'; import db from '../db'; -describe('/', () => { +describe('GET /', () => { let server; before(done => { diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index c4511ec..93b598b 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -4,7 +4,258 @@ import db from '../db'; import UserModel from '../models/user'; -describe('/user/new', () => { +describe('GET /users', () => { + let server; + + before(done => { + process.env.port = '2999'; + process.env.NODE_ENV = 'test'; + 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 all users', done => { + supertest(server) + .get('/users') + .auth('admin', 'Abc123!#') + .expect('Content-type', /json/) + .expect(200) + .end((err, res) => { + if (err) done (err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.users.length); + should(res.body).matchEach(user => { + should(user).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(user).have.property('_id').be.type('string'); + should(user).have.property('email').be.type('string'); + should(user).have.property('name').be.type('string'); + should(user).have.property('level').be.type('string'); + should(user).have.property('location').be.type('string'); + should(user).have.property('device_name').be.type('string'); + }); + done(); + }); + }); + it('rejects requests from non-admins', done => { + supertest(server) + .get('/users') + .auth('janedoe', 'Xyz890*)') + .expect('Content-type', /json/) + .expect(403) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Forbidden'}); + done(); + }); + }); + it('rejects requests from an admin API key', done => { + supertest(server) + .get('/users?key=5ea131671feb9c2ee0aafc9a') + .expect('Content-type', /json/) + .expect(401) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Unauthorized'}); + done(); + }); + }); +}); + + +describe('GET /user/{name}', () => { + let server; + + before(done => { + process.env.port = '2999'; + process.env.NODE_ENV = 'test'; + 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 own user details', done => { + supertest(server) + .get('/user') + .auth('janedoe', 'Xyz890*)') + .expect('Content-type', /json/) + .expect(200) + .end((err, res) => { + if (err) done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('email', 'jane.doe@bosch.com'); + should(res.body).have.property('name', 'janedoe'); + should(res.body).have.property('level', 'write'); + should(res.body).have.property('location', 'Rng'); + should(res.body).have.property('device_name', 'Alpha I'); + done(); + }); + }); + it('returns other user details for admin', done => { + supertest(server) + .get('/user/janedoe') + .auth('admin', 'Abc123!#') + .expect('Content-type', /json/) + .expect(200) + .end((err, res) => { + if (err) done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('email', 'jane.doe@bosch.com'); + should(res.body).have.property('name', 'janedoe'); + should(res.body).have.property('level', 'write'); + should(res.body).have.property('location', 'Rng'); + should(res.body).have.property('device_name', 'Alpha I'); + done(); + }); + }); + it('rejects requests from non-admins for another user', done => { + supertest(server) + .get('/user/admin') + .auth('janedoe', 'Xyz890*)') + .expect('Content-type', /json/) + .expect(403) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Forbidden'}); + done(); + }); + }); + it('rejects requests from a user API key', done => { + supertest(server) + .get('/user?key=5ea0450ed851c30a90e70899') + .expect('Content-type', /json/) + .expect(401) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Unauthorized'}); + done(); + }); + }); + it('rejects requests from an admin API key', done => { + supertest(server) + .get('/user/janedoe?key=5ea131671feb9c2ee0aafc9a') + .expect('Content-type', /json/) + .expect(401) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Unauthorized'}); + done(); + }); + }); +}); + + +describe('PUT /user/{name}', () => { + let server; + + before(done => { + process.env.port = '2999'; + process.env.NODE_ENV = 'test'; + 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 own user details', done => { + supertest(server) + .get('/user') + .auth('janedoe', 'Xyz890*)') + .expect('Content-type', /json/) + .expect(200) + .end((err, res) => { + if (err) done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('email', 'jane.doe@bosch.com'); + should(res.body).have.property('name', 'janedoe'); + should(res.body).have.property('level', 'write'); + should(res.body).have.property('location', 'Rng'); + should(res.body).have.property('device_name', 'Alpha I'); + done(); + }); + }); + it('returns other user details for admin', done => { + supertest(server) + .get('/user/janedoe') + .auth('admin', 'Abc123!#') + .expect('Content-type', /json/) + .expect(200) + .end((err, res) => { + if (err) done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('email', 'jane.doe@bosch.com'); + should(res.body).have.property('name', 'janedoe'); + should(res.body).have.property('level', 'write'); + should(res.body).have.property('location', 'Rng'); + should(res.body).have.property('device_name', 'Alpha I'); + done(); + }); + }); + it('rejects requests from non-admins for another user', done => { + supertest(server) + .get('/user/admin') + .auth('janedoe', 'Xyz890*)') + .expect('Content-type', /json/) + .expect(403) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Forbidden'}); + done(); + }); + }); + it('rejects requests from a user API key', done => { + supertest(server) + .get('/user?key=5ea0450ed851c30a90e70899') + .expect('Content-type', /json/) + .expect(401) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Unauthorized'}); + done(); + }); + }); + it('rejects requests from an admin API key', done => { + supertest(server) + .get('/user/janedoe?key=5ea131671feb9c2ee0aafc9a') + .expect('Content-type', /json/) + .expect(401) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Unauthorized'}); + done(); + }); + }); +}); + + +describe('POST /user/new', () => { let server; before(done => { @@ -32,6 +283,7 @@ describe('/user/new', () => { .expect(200) .end((err, res) => { if (err) done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); 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'); @@ -83,7 +335,7 @@ describe('/user/new', () => { it('rejects requests from non-admins', done => { supertest(server) .post('/user/new') - .auth('janedoe', 'Abc123!#') + .auth('janedoe', 'Xyz890*)') .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) .expect('Content-type', /json/) .expect(403) @@ -108,9 +360,7 @@ describe('/user/new', () => { }); - - -describe('/user/passreset', () => { +describe('POST /user/passreset', () => { let server; before(done => { @@ -191,11 +441,10 @@ describe('/user/passreset', () => { should(res.body).be.eql({status: 'OK'}); UserModel.find({name: 'janedoe'}).lean().exec( (err, data: any) => { if (err) return done(err); - console.log(data); should(data[0].pass).not.eql(oldpass); done(); }); }); }); }); -}); \ No newline at end of file +}); diff --git a/src/routes/user.ts b/src/routes/user.ts index cd67d14..0865fac 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -8,7 +8,24 @@ import mail from '../helpers/mail'; const router = express.Router(); router.get('/users', (req, res) => { - res.json({message: 'users up and running!'}); + if (!req.auth(res, ['admin'], 'basic')) return; + + UserModel.find({}).lean().exec( (err, data:any) => { + res.json(data.map(e => UserValidate.output(e)).filter(e => e !== null)); // validate all and filter null values from validation errors + }); +}); + +router.get('/user/:username*?', (req, res) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + let username = req.authDetails.username; + if (req.params.username !== undefined) { + if (!req.auth(res, ['admin'], 'basic')) return; + username = req.params.username; + } + + UserModel.findOne({name: username}).lean().exec( (err, data:any) => { + res.json(UserValidate.output(data)); // validate all and filter null values from validation errors + }); }); router.post('/user/new', (req, res, next) => { @@ -22,7 +39,7 @@ router.post('/user/new', (req, res, next) => { } // check that user does not already exist - UserModel.find({name: user.name}).lean().exec( 'find', (err, data) => { + UserModel.find({name: user.name}).lean().exec( (err, data:any) => { if (err) next(err); if (data.length > 0) { res.status(400).json({status: 'Username already taken'}); diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index 1cccf41..26becdd 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -33,13 +33,14 @@ export default class UserValidate { // validate input for user } static output (data) { // validate output from database for needed properties, strip everything else - return joi.object({ + const {value, error} = 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; + device_name: joi.string().allow('') + }).validate(data, {stripUnknown: true}) + return error !== undefined? null : value; } } diff --git a/src/test/db.json b/src/test/db.json index af2d78f..4c2c645 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -5,7 +5,7 @@ "_id": {"$oid":"5ea0450ed851c30a90e70894"}, "email": "jane.doe@bosch.com", "name": "janedoe", - "pass": "$2a$10$i872o3qR5V3JnbDArD8Z.eDo.BNPDBaR7dUX9KSEtl9pUjLyucy2K", + "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi", "level": "write", "location": "Rng", "device_name": "Alpha I", From 7a917c1f6bfcb3e03f1f80a6a42807e79f4842bd Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 24 Apr 2020 17:36:39 +0200 Subject: [PATCH 07/83] added PUT /user route --- oas/oas.yaml | 2 +- oas/user.yaml | 10 -- src/routes/user.spec.ts | 195 +++++++++++++++++++++++++++++++++++- src/routes/user.ts | 65 +++++++++++- src/routes/validate/user.ts | 74 +++++++++----- 5 files changed, 304 insertions(+), 42 deletions(-) diff --git a/oas/oas.yaml b/oas/oas.yaml index 03549c1..6e25698 100644 --- a/oas/oas.yaml +++ b/oas/oas.yaml @@ -23,7 +23,7 @@ info:
    • at least one digit
    • at least one lower case letter
    • at least one upper case letter
    • -
    • at least one of the following special characters: !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
    • +
    • at least one of the following special characters: !"#%&'()*+,-./:;<=>?@[\]^_`{|}~
    • no whitespace
    • at least 8 characters
    diff --git a/oas/user.yaml b/oas/user.yaml index 763a051..6c2c3fc 100644 --- a/oas/user.yaml +++ b/oas/user.yaml @@ -36,14 +36,10 @@ application/json: schema: $ref: 'oas.yaml#/components/schemas/User' - 400: - $ref: 'oas.yaml#/components/responses/400' 401: $ref: 'oas.yaml#/components/responses/401' 403: $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' 500: $ref: 'oas.yaml#/components/responses/500' put: @@ -86,8 +82,6 @@ $ref: 'oas.yaml#/components/responses/401' 403: $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' 500: $ref: 'oas.yaml#/components/responses/500' delete: @@ -106,8 +100,6 @@ $ref: 'oas.yaml#/components/responses/401' 403: $ref: 'oas.yaml#/components/responses/403' - 404: - $ref: 'oas.yaml#/components/responses/404' 500: $ref: 'oas.yaml#/components/responses/500' /user/{name}: @@ -127,8 +119,6 @@ application/json: schema: $ref: 'oas.yaml#/components/schemas/User' - 400: - $ref: 'oas.yaml#/components/responses/400' 401: $ref: 'oas.yaml#/components/responses/401' 403: diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index 93b598b..46860ac 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -160,6 +160,18 @@ describe('GET /user/{name}', () => { done(); }); }); + it('returns 404 for an unknown user', done => { + supertest(server) + .get('/user/unknown') + .auth('admin', 'Abc123!#') + .expect('Content-type', /json/) + .expect(404) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Not found'}); + done(); + }); + }); }); @@ -184,7 +196,8 @@ describe('PUT /user/{name}', () => { }); it('returns own user details', done => { supertest(server) - .get('/user') + .put('/user') + .send({}) .auth('janedoe', 'Xyz890*)') .expect('Content-type', /json/) .expect(200) @@ -202,7 +215,8 @@ describe('PUT /user/{name}', () => { }); it('returns other user details for admin', done => { supertest(server) - .get('/user/janedoe') + .put('/user/janedoe') + .send({}) .auth('admin', 'Abc123!#') .expect('Content-type', /json/) .expect(200) @@ -218,9 +232,118 @@ describe('PUT /user/{name}', () => { done(); }); }); + it('changes user details as given', done => { + supertest(server) + .put('/user') + .auth('admin', 'Abc123!#') + .send({name: 'adminnew', email: 'adminnew@bosch.com', pass: 'Abc123##', location: 'Abt', device_name: 'test'}) + .expect(200) + .end(err => { + if (err) done (err); + UserModel.find({name: 'adminnew'}).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', 'adminnew'); + should(data[0]).have.property('email', 'adminnew@bosch.com'); + should(data[0]).have.property('pass').not.eql('Abc123##'); + should(data[0]).have.property('level', 'admin'); + should(data[0]).have.property('location', 'Abt'); + should(data[0]).have.property('device_name', 'test'); + done(); + }); + }); + }); + it('lets the admin change a user level', done => { + supertest(server) + .put('/user/janedoe') + .auth('admin', 'Abc123!#') + .send({level: 'read'}) + .expect(200) + .end(err => { + if (err) done (err); + UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.property('level', 'read'); + done(); + }); + }); + }); + it('does not change the level', done => { + supertest(server) + .put('/user') + .auth('janedoe', 'Xyz890*)') + .send({level: 'read'}) + .expect(400) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Invalid body format'}); + UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.property('level', 'write'); + done(); + }); + }); + }); + it('rejects a username already in use', done => { + supertest(server) + .put('/user') + .auth('admin', 'Abc123!#') + .send({name: 'janedoe'}) + .expect(400) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Username already taken'}); + UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + done(); + }); + }); + }); + it('rejects invalid user details', done => { + supertest(server) + .put('/user') + .auth('admin', 'Abc123!#') + .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', location: 44, device_name: 'Alpha II'}) + .expect(400) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Invalid body format'}); + done(); + }); + }); + it('rejects an invalid email address', done => { + supertest(server) + .put('/user') + .auth('admin', 'Abc123!#') + .send({email: 'john.doe'}) + .expect(400) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Invalid body format'}); + done(); + }); + }); + it('rejects an invalid password', done => { + supertest(server) + .put('/user') + .auth('admin', 'Abc123!#') + .send({pass: 'password'}) + .expect(400) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Invalid body format'}); + done(); + }); + }); it('rejects requests from non-admins for another user', done => { supertest(server) - .get('/user/admin') + .put('/user/admin') + .send({}) .auth('janedoe', 'Xyz890*)') .expect('Content-type', /json/) .expect(403) @@ -232,7 +355,8 @@ describe('PUT /user/{name}', () => { }); it('rejects requests from a user API key', done => { supertest(server) - .get('/user?key=5ea0450ed851c30a90e70899') + .put('/user?key=5ea0450ed851c30a90e70899') + .send({}) .expect('Content-type', /json/) .expect(401) .end((err, res) => { @@ -243,7 +367,8 @@ describe('PUT /user/{name}', () => { }); it('rejects requests from an admin API key', done => { supertest(server) - .get('/user/janedoe?key=5ea131671feb9c2ee0aafc9a') + .put('/user/janedoe?key=5ea131671feb9c2ee0aafc9a') + .send({}) .expect('Content-type', /json/) .expect(401) .end((err, res) => { @@ -252,6 +377,18 @@ describe('PUT /user/{name}', () => { done(); }); }); + it('returns 404 for an unknown user', done => { + supertest(server) + .put('/user/unknown') + .auth('admin', 'Abc123!#') + .send({}) + .expect(404) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Not found'}); + done(); + }); + }); }); @@ -332,6 +469,54 @@ describe('POST /user/new', () => { }); }); }); + it('rejects invalid user details', done => { + supertest(server) + .post('/user/new') + .auth('admin', 'Abc123!#') + .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 44, device_name: 'Alpha II'}) + .expect(400) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Invalid body format'}); + done(); + }); + }); + it('rejects an invalid user level', done => { + supertest(server) + .post('/user/new') + .auth('admin', 'Abc123!#') + .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'xxx', location: 'Rng', device_name: 'Alpha II'}) + .expect(400) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Invalid body format'}); + done(); + }); + }); + it('rejects an invalid email address', done => { + supertest(server) + .post('/user/new') + .auth('admin', 'Abc123!#') + .send({email: 'john.doe', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) + .expect(400) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Invalid body format'}); + done(); + }); + }); + it('rejects an invalid password', done => { + supertest(server) + .post('/user/new') + .auth('admin', 'Abc123!#') + .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'password', level: 'read', location: 'Rng', device_name: 'Alpha II'}) + .expect(400) + .end((err, res) => { + if (err) done (err); + should(res.body).be.eql({status: 'Invalid body format'}); + done(); + }); + }); it('rejects requests from non-admins', done => { supertest(server) .post('/user/new') diff --git a/src/routes/user.ts b/src/routes/user.ts index 0865fac..d362c79 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -15,7 +15,7 @@ router.get('/users', (req, res) => { }); }); -router.get('/user/:username*?', (req, res) => { +router.get('/user/:username*?', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; let username = req.authDetails.username; if (req.params.username !== undefined) { @@ -24,15 +24,74 @@ router.get('/user/:username*?', (req, res) => { } UserModel.findOne({name: username}).lean().exec( (err, data:any) => { - res.json(UserValidate.output(data)); // validate all and filter null values from validation errors + if (err) next(err); + if (data) { + res.json(UserValidate.output(data)); // validate all and filter null values from validation errors + } + else { + res.status(404).json({status: 'Not found'}); + } }); }); +router.put('/user/:username*?', (req, res, next) => { + console.log(req.authDetails); + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + let username = req.authDetails.username; + if (req.params.username !== undefined) { + if (!req.auth(res, ['admin'], 'basic')) return; + username = req.params.username; + } + const {error, value: user} = UserValidate.input(req.body, 'change' + (req.authDetails.level === 'admin'? 'admin' : '')); + console.log(error); + console.log(user); + if(error !== undefined) { + res.status(400).json({status: 'Invalid body format'}); + return; + } + + if (user.hasOwnProperty('pass')) { + user.pass = bcrypt.hashSync(user.pass, 10); + } + + // check that user does not already exist if new name was specified + if (user.hasOwnProperty('name') && user.name !== username) { + UserModel.find({name: user.name}).lean().exec( (err, data:any) => { + if (err) next(err); + if (data.length > 0) { + res.status(400).json({status: 'Username already taken'}); + return; + } + + UserModel.findOneAndUpdate({name: username}, user, {new: true}).lean().exec( (err, data:any) => { + if (err) next(err); + if (data) { + res.json(UserValidate.output(data)); // validate all and filter null values from validation errors + } + else { + res.status(404).json({status: 'Not found'}); + } + }); + }); + } + else { + UserModel.findOneAndUpdate({name: username}, user, {new: true}).lean().exec( (err, data:any) => { + if (err) next(err); + if (data) { + res.json(UserValidate.output(data)); // validate all and filter null values from validation errors + } + else { + res.status(404).json({status: 'Not found'}); + } + }); + } +}); + router.post('/user/new', (req, res, next) => { if (!req.auth(res, ['admin'], 'basic')) return; // validate input - const {error, value: user} = UserValidate.input(req.body); + const {error, value: user} = UserValidate.input(req.body, 'new'); if(error !== undefined) { res.status(400).json({status: 'Invalid body format'}); return; diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index 26becdd..68f743d 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -2,34 +2,62 @@ 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(), + private static user = { + _id: joi.any(), + name: joi.string() + .alphanum() + .lowercase(), - email: joi.string() - .email({minDomainSegments: 2}) - .lowercase() - .required(), + email: joi.string() + .email({minDomainSegments: 2}) + .lowercase(), - pass: joi.string() - .pattern(new RegExp('^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#$%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$')) - .required(), + pass: joi.string() + .pattern(new RegExp('^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$')), - level: joi.string() - .valid(...globals.levels) - .required(), + level: joi.string() + .valid(...globals.levels), - location: joi.string() - .alphanum() - .required(), + location: joi.string() + .alphanum(), - device_name: joi.string() - .allow('') - .required() - }).validate(data); + device_name: joi.string() + .allow('') + }; + + static input (data, param) { + if (param === 'new') { + return joi.object({ + name: this.user.name.required(), + email: this.user.email.required(), + pass: this.user.pass.required(), + level: this.user.level.required(), + location: this.user.location.required(), + device_name: this.user.device_name.required() + }).validate(data); + } + else if (param === 'change') { + return joi.object({ + name: this.user.name, + email: this.user.email, + pass: this.user.pass, + location: this.user.location, + device_name: this.user.device_name + }).validate(data); + } + else if (param === 'changeadmin') { + return joi.object({ + name: this.user.name, + email: this.user.email, + pass: this.user.pass, + level: this.user.level, + location: this.user.location, + device_name: this.user.device_name + }).validate(data); + } + else { + return{error: 'No parameter specified!', value: {}}; + } } static output (data) { // validate output from database for needed properties, strip everything else From eaa6484dcab12947d15fcaa36bfdaae896577e9e Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 27 Apr 2020 11:44:28 +0200 Subject: [PATCH 08/83] added test helper and rewrote tests --- .gitignore | 1 + src/helpers/test.ts | 88 ++++ src/routes/root.spec.ts | 180 ++----- src/routes/user.spec.ts | 1012 ++++++++++++++++----------------------- 4 files changed, 567 insertions(+), 714 deletions(-) create mode 100644 src/helpers/test.ts diff --git a/.gitignore b/.gitignore index 0a811ca..645d3a5 100644 --- a/.gitignore +++ b/.gitignore @@ -112,3 +112,4 @@ dist **/.idea/tasks.xml **/.idea/shelf **/.idea/*.iml +/tmp/ diff --git a/src/helpers/test.ts b/src/helpers/test.ts new file mode 100644 index 0000000..4a537aa --- /dev/null +++ b/src/helpers/test.ts @@ -0,0 +1,88 @@ +import supertest from 'supertest'; +import should from 'should/as-function'; +import db from "../db"; + + +export default class TestHelper { + public static auth = { + admin: {pass: 'Abc123!#', key: '5ea131671feb9c2ee0aafc9a'}, + janedoe: {pass: 'Xyz890*)', key: '5ea0450ed851c30a90e70899'} + } + public static res = { + 400: {status: 'Bad request'}, + 401: {status: 'Unauthorized'}, + 403: {status: 'Forbidden'}, + 404: {status: 'Not found'}, + } + + static before (done) { + process.env.port = '2999'; + process.env.NODE_ENV = 'test'; + db.connect('test', done); + } + + static beforeEach (server, 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); + }); + return server + } + + static afterEach (server, done) { + server.close(done); + } + + static request (server, done, options) { // options in form: {method, url, auth: {key/basic: 'name' or 'key'/{name, pass}}, httpStatus, req, res} + let st = supertest(server); + if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('key')) { + options.url += '?key=' + (this.auth.hasOwnProperty(options.auth.key)? this.auth[options.auth.key].key : options.auth.key); + } + switch (options.method) { + case 'get': + st = st.get(options.url) + break; + case 'post': + st = st.post(options.url) + break; + case 'put': + st = st.put(options.url) + break; + case 'delete': + st = st.delete(options.url) + break; + } + if (options.hasOwnProperty('req')) { + st = st.send(options.req); + } + if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('basic')) { + if (this.auth.hasOwnProperty(options.auth.basic)) { + st = st.auth(options.auth.basic, this.auth[options.auth.basic].pass) + } + else { + st = st.auth(options.auth.basic.name, options.auth.basic.pass) + } + } + st = st.expect('Content-type', /json/) + .expect(options.httpStatus); + if (options.hasOwnProperty('res')) { + return st.end((err, res) => { + if (err) return done (err); + should(res.body).be.eql(options.res); + done(); + }); + } + else if (this.res.hasOwnProperty(options.httpStatus) && options.default !== false) { + return st.end((err, res) => { + if (err) return done (err); + should(res.body).be.eql(this.res[options.httpStatus]); + done(); + }); + } + else { + return st; + } + } +} \ No newline at end of file diff --git a/src/routes/root.spec.ts b/src/routes/root.spec.ts index 9372259..25be1ba 100644 --- a/src/routes/root.spec.ts +++ b/src/routes/root.spec.ts @@ -1,145 +1,69 @@ -import supertest from 'supertest'; -import should from 'should/as-function'; -import db from '../db'; +import TestHelper from "../helpers/test"; -describe('GET /', () => { +describe('/', () => { let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); - before(done => { - process.env.port = '2999'; - process.env.NODE_ENV = 'test'; - 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); + describe('GET /', () => { + it('returns the root message', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/', + httpStatus: 200, + res: {status: 'API server up and running!'} + }); }); }); - afterEach(done => { - server.close(done); - }); - it('returns the root message', done => { - supertest(server) - .get('/') - .expect('Content-type', /json/) - .expect(200) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'API server up and running!'}); - done(); + + describe('Unknown routes', () => { + it('return a 404 message', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/unknownroute', + httpStatus: 404 }); - }); -}); - -describe('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('return a 404 message', done => { - supertest(server) - .get('/unknownroute') - .expect(404) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Not found'}); - done(); + + describe('An unauthorized request', () => { + it('returns a 401 message', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/authorized', + httpStatus: 401 + }); + }); + it('does not work with correct username', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/authorized', + auth: {name: 'admin', pass: 'Abc123!!'}, + httpStatus: 401 }); - }); -}); - -describe('An unauthorized request', () => { - 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 401 message', done => { - supertest(server) - .get('/authorized') - .expect(401) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Unauthorized'}); - done(); - }); - }); - it('does not work with correct username', done => { - supertest(server) - .get('/authorized') - .auth('admin', 'Abc123!!') - .expect(401) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Unauthorized'}); - done(); - }); - }); -}); -describe('An authorized request', () => { - 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); + describe('An authorized request', () => { + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/authorized', + auth: {key: 'admin'}, + httpStatus: 200, + res: {status: 'Authorization successful', method: 'key'} + }); }); - }); - afterEach(done => { - server.close(done); - }); - it('works with an API key', done => { - supertest(server) - .get('/authorized?key=5ea131671feb9c2ee0aafc9a') - .expect(200) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Authorization successful', method: 'key'}); - done(); - }); - }); - it('works with basic auth', done => { - supertest(server) - .get('/authorized') - .auth('admin', 'Abc123!#') - .expect(200) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Authorization successful', method: 'basic'}); - done(); + it('works with basic auth', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/authorized', + auth: {basic: 'admin'}, + httpStatus: 200, + res: {status: 'Authorization successful', method: 'basic'} }); + }); }); }); \ No newline at end of file diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index 46860ac..8098d9c 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -1,36 +1,23 @@ -import supertest from 'supertest'; import should from 'should/as-function'; -import db from '../db'; import UserModel from '../models/user'; +import TestHelper from "../helpers/test"; -describe('GET /users', () => { +describe('/user', () => { let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); - before(done => { - process.env.port = '2999'; - process.env.NODE_ENV = 'test'; - 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 all users', done => { - supertest(server) - .get('/users') - .auth('admin', 'Abc123!#') - .expect('Content-type', /json/) - .expect(200) - .end((err, res) => { - if (err) done (err); + describe('GET /users', () => { + it('returns all users', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/users', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); const json = require('../test/db.json'); should(res.body).have.lengthOf(json.collections.users.length); should(res.body).matchEach(user => { @@ -44,60 +31,34 @@ describe('GET /users', () => { }); done(); }); - }); - it('rejects requests from non-admins', done => { - supertest(server) - .get('/users') - .auth('janedoe', 'Xyz890*)') - .expect('Content-type', /json/) - .expect(403) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Forbidden'}); - done(); + }); + it('rejects requests from non-admins', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/users', + auth: {basic: 'janedoe'}, + httpStatus: 403 }); - }); - it('rejects requests from an admin API key', done => { - supertest(server) - .get('/users?key=5ea131671feb9c2ee0aafc9a') - .expect('Content-type', /json/) - .expect(401) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Unauthorized'}); - done(); + }); + it('rejects requests from an admin API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/users', + auth: {key: 'admin'}, + httpStatus: 401 }); - }); -}); - - -describe('GET /user/{name}', () => { - let server; - - before(done => { - process.env.port = '2999'; - process.env.NODE_ENV = 'test'; - 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 own user details', done => { - supertest(server) - .get('/user') - .auth('janedoe', 'Xyz890*)') - .expect('Content-type', /json/) - .expect(200) - .end((err, res) => { - if (err) done (err); + + describe('GET /user/{name}', () => { + it('returns own user details', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done (err); should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); should(res.body).have.property('_id').be.type('string'); should(res.body).have.property('email', 'jane.doe@bosch.com'); @@ -107,529 +68,408 @@ describe('GET /user/{name}', () => { should(res.body).have.property('device_name', 'Alpha I'); done(); }); - }); - it('returns other user details for admin', done => { - supertest(server) - .get('/user/janedoe') - .auth('admin', 'Abc123!#') - .expect('Content-type', /json/) - .expect(200) - .end((err, res) => { - if (err) done (err); - should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); - should(res.body).have.property('_id').be.type('string'); - should(res.body).have.property('email', 'jane.doe@bosch.com'); - should(res.body).have.property('name', 'janedoe'); - should(res.body).have.property('level', 'write'); - should(res.body).have.property('location', 'Rng'); - should(res.body).have.property('device_name', 'Alpha I'); - done(); + }); + it('returns other user details for admin', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/janedoe', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('email', 'jane.doe@bosch.com'); + should(res.body).have.property('name', 'janedoe'); + should(res.body).have.property('level', 'write'); + should(res.body).have.property('location', 'Rng'); + should(res.body).have.property('device_name', 'Alpha I'); + done(); + }); + }); + it('rejects requests from non-admins for another user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/admin', + auth: {basic: 'janedoe'}, + httpStatus: 403 }); - }); - it('rejects requests from non-admins for another user', done => { - supertest(server) - .get('/user/admin') - .auth('janedoe', 'Xyz890*)') - .expect('Content-type', /json/) - .expect(403) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Forbidden'}); - done(); + }); + it('rejects requests from a user API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user', + auth: {key: 'janedoe'}, + httpStatus: 401 }); - }); - it('rejects requests from a user API key', done => { - supertest(server) - .get('/user?key=5ea0450ed851c30a90e70899') - .expect('Content-type', /json/) - .expect(401) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Unauthorized'}); - done(); + }); + it('rejects requests from an admin API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/janedoe', + auth: {key: 'janedoe'}, + httpStatus: 401 }); - }); - it('rejects requests from an admin API key', done => { - supertest(server) - .get('/user/janedoe?key=5ea131671feb9c2ee0aafc9a') - .expect('Content-type', /json/) - .expect(401) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Unauthorized'}); - done(); + }); + it('returns 404 for an unknown user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/unknown', + auth: {basic: 'admin'}, + httpStatus: 404 }); - }); - it('returns 404 for an unknown user', done => { - supertest(server) - .get('/user/unknown') - .auth('admin', 'Abc123!#') - .expect('Content-type', /json/) - .expect(404) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Not found'}); - done(); - }); - }); -}); - - -describe('PUT /user/{name}', () => { - let server; - - before(done => { - process.env.port = '2999'; - process.env.NODE_ENV = 'test'; - 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 own user details', done => { - supertest(server) - .put('/user') - .send({}) - .auth('janedoe', 'Xyz890*)') - .expect('Content-type', /json/) - .expect(200) - .end((err, res) => { - if (err) done (err); - should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); - should(res.body).have.property('_id').be.type('string'); - should(res.body).have.property('email', 'jane.doe@bosch.com'); - should(res.body).have.property('name', 'janedoe'); - should(res.body).have.property('level', 'write'); - should(res.body).have.property('location', 'Rng'); - should(res.body).have.property('device_name', 'Alpha I'); - done(); - }); - }); - it('returns other user details for admin', done => { - supertest(server) - .put('/user/janedoe') - .send({}) - .auth('admin', 'Abc123!#') - .expect('Content-type', /json/) - .expect(200) - .end((err, res) => { - if (err) done (err); - should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); - should(res.body).have.property('_id').be.type('string'); - should(res.body).have.property('email', 'jane.doe@bosch.com'); - should(res.body).have.property('name', 'janedoe'); - should(res.body).have.property('level', 'write'); - should(res.body).have.property('location', 'Rng'); - should(res.body).have.property('device_name', 'Alpha I'); - done(); - }); - }); - it('changes user details as given', done => { - supertest(server) - .put('/user') - .auth('admin', 'Abc123!#') - .send({name: 'adminnew', email: 'adminnew@bosch.com', pass: 'Abc123##', location: 'Abt', device_name: 'test'}) - .expect(200) - .end(err => { - if (err) done (err); - UserModel.find({name: 'adminnew'}).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', 'adminnew'); - should(data[0]).have.property('email', 'adminnew@bosch.com'); - should(data[0]).have.property('pass').not.eql('Abc123##'); - should(data[0]).have.property('level', 'admin'); - should(data[0]).have.property('location', 'Abt'); - should(data[0]).have.property('device_name', 'test'); - done(); - }); - }); - }); - it('lets the admin change a user level', done => { - supertest(server) - .put('/user/janedoe') - .auth('admin', 'Abc123!#') - .send({level: 'read'}) - .expect(200) - .end(err => { - if (err) done (err); - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { - if (err) return done(err); - should(data).have.lengthOf(1); - should(data[0]).have.property('level', 'read'); - done(); - }); - }); - }); - it('does not change the level', done => { - supertest(server) - .put('/user') - .auth('janedoe', 'Xyz890*)') - .send({level: 'read'}) - .expect(400) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Invalid body format'}); - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { - if (err) return done(err); - should(data).have.lengthOf(1); - should(data[0]).have.property('level', 'write'); - done(); - }); - }); - }); - it('rejects a username already in use', done => { - supertest(server) - .put('/user') - .auth('admin', 'Abc123!#') - .send({name: 'janedoe'}) - .expect(400) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Username already taken'}); - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { - if (err) return done(err); - should(data).have.lengthOf(1); - done(); - }); - }); - }); - it('rejects invalid user details', done => { - supertest(server) - .put('/user') - .auth('admin', 'Abc123!#') - .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', location: 44, device_name: 'Alpha II'}) - .expect(400) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Invalid body format'}); - done(); - }); - }); - it('rejects an invalid email address', done => { - supertest(server) - .put('/user') - .auth('admin', 'Abc123!#') - .send({email: 'john.doe'}) - .expect(400) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Invalid body format'}); - done(); - }); - }); - it('rejects an invalid password', done => { - supertest(server) - .put('/user') - .auth('admin', 'Abc123!#') - .send({pass: 'password'}) - .expect(400) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Invalid body format'}); - done(); - }); - }); - it('rejects requests from non-admins for another user', done => { - supertest(server) - .put('/user/admin') - .send({}) - .auth('janedoe', 'Xyz890*)') - .expect('Content-type', /json/) - .expect(403) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Forbidden'}); - done(); - }); - }); - it('rejects requests from a user API key', done => { - supertest(server) - .put('/user?key=5ea0450ed851c30a90e70899') - .send({}) - .expect('Content-type', /json/) - .expect(401) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Unauthorized'}); - done(); - }); - }); - it('rejects requests from an admin API key', done => { - supertest(server) - .put('/user/janedoe?key=5ea131671feb9c2ee0aafc9a') - .send({}) - .expect('Content-type', /json/) - .expect(401) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Unauthorized'}); - done(); - }); - }); - it('returns 404 for an unknown user', done => { - supertest(server) - .put('/user/unknown') - .auth('admin', 'Abc123!#') - .send({}) - .expect(404) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Not found'}); - done(); - }); - }); -}); - -describe('POST /user/new', () => { - let server; - - before(done => { - process.env.port = '2999'; - process.env.NODE_ENV = 'test'; - 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); + describe('PUT /user/{name}', () => { + it('returns own user details', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('email', 'jane.doe@bosch.com'); + should(res.body).have.property('name', 'janedoe'); + should(res.body).have.property('level', 'write'); + should(res.body).have.property('location', 'Rng'); + should(res.body).have.property('device_name', 'Alpha I'); + done(); + }); }); - }); - afterEach(done => { - server.close(done); - }); - it('returns the added user data', done => { - supertest(server) - .post('/user/new') - .auth('admin', 'Abc123!#') - .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) - .expect('Content-type', /json/) - .expect(200) - .end((err, res) => { - if (err) done (err); - should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); - 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') - .auth('admin', 'Abc123!#') - .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) - .expect(200) - .end(err => { - if (err) 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'); + it('returns other user details for admin', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user/janedoe', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('email', 'jane.doe@bosch.com'); + should(res.body).have.property('name', 'janedoe'); + should(res.body).have.property('level', 'write'); + should(res.body).have.property('location', 'Rng'); + should(res.body).have.property('device_name', 'Alpha I'); done(); }); - }); - }); - it('rejects a username already in use', done => { - supertest(server) - .post('/user/new') - .auth('admin', 'Abc123!#') - .send({email: 'j.doe@bosch.com', name: 'janedoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) - .expect(400) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Username already taken'}); - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { - if (err) return done(err); - should(data).have.lengthOf(1); - done(); - }); - }); - }); - it('rejects invalid user details', done => { - supertest(server) - .post('/user/new') - .auth('admin', 'Abc123!#') - .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 44, device_name: 'Alpha II'}) - .expect(400) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Invalid body format'}); - done(); - }); - }); - it('rejects an invalid user level', done => { - supertest(server) - .post('/user/new') - .auth('admin', 'Abc123!#') - .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'xxx', location: 'Rng', device_name: 'Alpha II'}) - .expect(400) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Invalid body format'}); - done(); - }); - }); - it('rejects an invalid email address', done => { - supertest(server) - .post('/user/new') - .auth('admin', 'Abc123!#') - .send({email: 'john.doe', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) - .expect(400) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Invalid body format'}); - done(); - }); - }); - it('rejects an invalid password', done => { - supertest(server) - .post('/user/new') - .auth('admin', 'Abc123!#') - .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'password', level: 'read', location: 'Rng', device_name: 'Alpha II'}) - .expect(400) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Invalid body format'}); - done(); - }); - }); - it('rejects requests from non-admins', done => { - supertest(server) - .post('/user/new') - .auth('janedoe', 'Xyz890*)') - .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) - .expect('Content-type', /json/) - .expect(403) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Forbidden'}); - done(); - }); - }); - it('rejects requests from an admin API key', done => { - supertest(server) - .post('/user/new?key=5ea131671feb9c2ee0aafc9a') - .send({email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}) - .expect('Content-type', /json/) - .expect(401) - .end((err, res) => { - if (err) done (err); - should(res.body).be.eql({status: 'Unauthorized'}); - done(); - }); - }); -}); - - -describe('POST /user/passreset', () => { - let server; - - before(done => { - process.env.port = '2999'; - process.env.NODE_ENV = 'test'; - 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 ok response', done => { - supertest(server) - .post('/user/passreset') - .send({ - email: 'jane.doe@bosch.com', - name: 'janedoe' - }) - .expect('Content-type', /json/) - .expect(200) - .end((err, res) => { - if (err) done(err); - should(res.body).be.eql({status: 'OK'}); - done(); - }); - }); - it('returns 404 for wrong username/email combo', done => { - supertest(server) - .post('/user/passreset') - .send({ - email: 'jane.doe@bosch.com', - name: 'admin' - }) - .expect('Content-type', /json/) - .expect(404) - .end((err, res) => { - if (err) done(err); - should(res.body).be.eql({status: 'Not found'}); - done(); - }); - }); - it('returns 404 for unknown username', done => { - supertest(server) - .post('/user/passreset') - .send({ - email: 'jane.doe@bosch.com', - name: 'admin' - }) - .expect('Content-type', /json/) - .expect(404) - .end((err, res) => { - if (err) done(err); - should(res.body).be.eql({status: 'Not found'}); - done(); - }); - }); - it('changes the user password', done => { - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data: any) => { - if (err) return done(err); - const oldpass = data[0].pass; - supertest(server) - .post('/user/passreset') - .send({ - email: 'jane.doe@bosch.com', - name: 'janedoe' - }) - .expect('Content-type', /json/) - .expect(200) - .end((err, res) => { - if (err) done(err); - should(res.body).be.eql({status: 'OK'}); - UserModel.find({name: 'janedoe'}).lean().exec( (err, data: any) => { + it('changes user details as given', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'adminnew', email: 'adminnew@bosch.com', pass: 'Abc123##', location: 'Abt', device_name: 'test'} + }).end(err => { + if (err) return done (err); + UserModel.find({name: 'adminnew'}).lean().exec( 'find', (err, data) => { if (err) return done(err); - should(data[0].pass).not.eql(oldpass); + 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', 'adminnew'); + should(data[0]).have.property('email', 'adminnew@bosch.com'); + should(data[0]).have.property('pass').not.eql('Abc123##'); + should(data[0]).have.property('level', 'admin'); + should(data[0]).have.property('location', 'Abt'); + should(data[0]).have.property('device_name', 'test'); done(); }); }); }); + it('lets the admin change a user level', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user/janedoe', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {level: 'read'} + }).end(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); + should(data[0]).have.property('level', 'read'); + done(); + }); + }); + }); + it('does not change the level', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'janedoe'}, + httpStatus: 400, default: false, + req: {level: 'read'} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'Invalid body format'}); + UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.property('level', 'write'); + done(); + }); + }); + }); + it('rejects a username already in use', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'admin'}, + httpStatus: 400, default: false, + req: {name: 'janedoe'} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'Username already taken'}); + UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + done(); + }); + }); + }); + it('rejects invalid user details', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', location: 44, device_name: 'Alpha II'}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects an invalid email address', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {email: 'john.doe'}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects an invalid password', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {pass: 'password'}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects requests from non-admins for another user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user/admin', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('rejects requests from a user API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects requests from an admin API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user/janedoe', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('returns 404 for an unknown user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user/unknown', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); }); -}); + + describe('POST /user/new', () => { + it('returns the added user data', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name'); + 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 => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} + }).end(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 => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, default: false, + req: {email: 'j.doe@bosch.com', name: 'janedoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'Username already taken'}); + UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + done(); + }); + }); + }); + it('rejects invalid user details', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 44, device_name: 'Alpha II'}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects an invalid user level', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'xxx', location: 'Rng', device_name: 'Alpha II'}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects an invalid email address', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {email: 'john.doe', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects an invalid password', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'password', level: 'read', location: 'Rng', device_name: 'Alpha II'}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects requests from non-admins', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} + }); + }); + it('rejects requests from an admin API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {key: 'admin'}, + httpStatus: 401, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} + }); + }); + }); + + describe('POST /user/passreset', () => { + it('returns the ok response', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/passreset', + httpStatus: 200, + req: {email: 'jane.doe@bosch.com', name: 'janedoe'}, + res: {status: 'OK'} + }); + }); + it('returns 404 for wrong username/email combo', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/passreset', + httpStatus: 404, + req: {email: 'jane.doe@bosch.com', name: 'admin'} + }); + }); + it('returns 404 for unknown username', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/passreset', + httpStatus: 404, + req: {email: 'jane.doe@bosch.com', name: 'username'} + }); + }); + it('changes the user password', done => { + UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data: any) => { + if (err) return done(err); + const oldpass = data[0].pass; + TestHelper.request(server, done, { + method: 'post', + url: '/user/passreset', + httpStatus: 200, + req: {email: 'jane.doe@bosch.com', name: 'janedoe'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + UserModel.find({name: 'janedoe'}).lean().exec( (err, data: any) => { + if (err) return done(err); + should(data[0].pass).not.eql(oldpass); + done(); + }); + }); + }); + }); + }); +}); \ No newline at end of file From 1eff39bb167515837406c7c757659aac620dac5c Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 27 Apr 2020 14:26:51 +0200 Subject: [PATCH 09/83] added /user/key and edited /user regex --- oas/user.yaml | 5 ++++- src/routes/user.spec.ts | 20 ++++++++++++++++++++ src/routes/user.ts | 15 +++++++++++++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/oas/user.yaml b/oas/user.yaml index 6c2c3fc..c8c282d 100644 --- a/oas/user.yaml +++ b/oas/user.yaml @@ -191,7 +191,10 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/User' + properties: + key: + type: string + example: 5ea0450ed851c30a90e70899 401: $ref: 'oas.yaml#/components/responses/401' 500: diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index 8098d9c..60f5b4d 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -472,4 +472,24 @@ describe('/user', () => { }); }); }); + + describe('GET /user/key', () => { + it('returns the right API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/key', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {key: TestHelper.auth.janedoe.key} + }); + }); + it('rejects requests from an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/key', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + }) }); \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts index d362c79..26f21cc 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -15,7 +15,8 @@ router.get('/users', (req, res) => { }); }); -router.get('/user/:username*?', (req, res, next) => { +router.get('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new. See https://forbeslindesay.github.io/express-route-tester/ for the generated regex + req.params.username = req.params[0]; if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; let username = req.authDetails.username; if (req.params.username !== undefined) { @@ -34,7 +35,7 @@ router.get('/user/:username*?', (req, res, next) => { }); }); -router.put('/user/:username*?', (req, res, next) => { +router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new console.log(req.authDetails); if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; let username = req.authDetails.username; @@ -87,6 +88,16 @@ router.put('/user/:username*?', (req, res, next) => { } }); +router.get('/user/key', (req, res, next) => { + console.log('hmm'); + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + + UserModel.findOne({name: req.authDetails.username}).lean().exec( (err, data:any) => { + if (err) next(err); + res.json({key: data.key}); + }); +}); + router.post('/user/new', (req, res, next) => { if (!req.auth(res, ['admin'], 'basic')) return; From 5a911a455b9d21c81d4c3267d17121115a8f6ddc Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 27 Apr 2020 15:10:14 +0200 Subject: [PATCH 10/83] added /user DELETE route --- oas/user.yaml | 16 ++-- package-lock.json | 172 ++++++++++++++++++++++++------------ package.json | 3 +- src/routes/user.spec.ts | 87 ++++++++++++++++++ src/routes/user.ts | 28 ++++-- src/routes/validate/user.ts | 6 ++ 6 files changed, 239 insertions(+), 73 deletions(-) diff --git a/oas/user.yaml b/oas/user.yaml index c8c282d..7d9d225 100644 --- a/oas/user.yaml +++ b/oas/user.yaml @@ -43,7 +43,7 @@ 500: $ref: 'oas.yaml#/components/responses/500' put: - summary: TODO change user details + summary: change user details description: 'Auth: basic, levels: read, write, maintain, admin' tags: - /user @@ -85,7 +85,7 @@ 500: $ref: 'oas.yaml#/components/responses/500' delete: - summary: TODO delete user + summary: delete user description: 'Auth: basic, levels: read, write, maintain, admin' tags: - /user @@ -94,12 +94,8 @@ responses: 200: $ref: 'oas.yaml#/components/responses/Ok' - 400: - $ref: 'oas.yaml#/components/responses/400' 401: $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' 500: $ref: 'oas.yaml#/components/responses/500' /user/{name}: @@ -128,7 +124,7 @@ 500: $ref: 'oas.yaml#/components/responses/500' put: - summary: TODO change user details + summary: change user details description: 'Auth: basic, levels: admin' tags: - /user @@ -158,7 +154,7 @@ 500: $ref: 'oas.yaml#/components/responses/500' delete: - summary: TODO delete user + summary: delete user description: 'Auth: basic, levels: admin' tags: - /user @@ -167,8 +163,6 @@ responses: 200: $ref: 'oas.yaml#/components/responses/Ok' - 400: - $ref: 'oas.yaml#/components/responses/400' 401: $ref: 'oas.yaml#/components/responses/401' 403: @@ -179,7 +173,7 @@ $ref: 'oas.yaml#/components/responses/500' /user/key: get: - summary: TODO get API key for the user + summary: get API key for the user description: 'Auth: basic, levels: read, write, maintain, dev, admin' tags: - /user diff --git a/package-lock.json b/package-lock.json index 5249707..9b82ecb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,6 +98,15 @@ "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-3.0.0.tgz", "integrity": "sha512-nohgNyv+1ViVcubKBh0+XiNJ3dO8nYu///9aJ4cgSqv70gBL+94SNy/iC2NLzKPT2Zt/QavrOkBVbZRLZmw6NQ==" }, + "@types/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, "@types/bson": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.2.tgz", @@ -111,6 +120,39 @@ "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" }, + "@types/connect": { + "version": "3.4.33", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.33.tgz", + "integrity": "sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==", + "requires": { + "@types/node": "*" + } + }, + "@types/express": { + "version": "4.17.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.6.tgz", + "integrity": "sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w==", + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "*", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.5", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.5.tgz", + "integrity": "sha512-578YH5Lt88AKoADy0b2jQGwJtrBxezXtVe/MBqWXKZpqx91SnC0pVkVCcxcytz3lWW+cHBYDi3Ysh0WXc+rAYw==", + "requires": { + "@types/node": "*", + "@types/range-parser": "*" + } + }, + "@types/mime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", + "integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==" + }, "@types/mocha": { "version": "5.2.7", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", @@ -139,6 +181,25 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.6.tgz", "integrity": "sha512-Jg1F+bmxcpENHP23sVKkNuU3uaxPnsBMW0cLjleiikFKomJQbsn0Cqk2yDvQArqzZN6ABfBkZ0To7pQ8sLdWDg==" }, + "@types/qs": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.1.tgz", + "integrity": "sha512-lhbQXx9HKZAPgBkISrBcmAcMpZsmpe/Cd/hY7LGZS5OfkySUBItnPZHgQPssWYUET8elF+yCFBbP1Q0RZPTdaw==" + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" + }, + "@types/serve-static": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz", + "integrity": "sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==", + "requires": { + "@types/express-serve-static-core": "*", + "@types/mime": "*" + } + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -745,9 +806,9 @@ } }, "es-abstract": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.0.tgz", - "integrity": "sha512-yYkE07YF+6SIBmg1MsJ9dlub5L48Ek7X0qz+c/CPCHS9EBXfESorzng4cJQjJW5/pB6vDF41u7F8vUhLVDqIug==", + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", + "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==", "dev": true, "requires": { "es-to-primitive": "^1.2.1", @@ -1299,12 +1360,12 @@ "dev": true }, "log-symbols": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", - "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", "dev": true, "requires": { - "chalk": "^2.0.1" + "chalk": "^2.4.2" } }, "lowercase-keys": { @@ -1393,9 +1454,9 @@ } }, "mocha": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-7.0.0.tgz", - "integrity": "sha512-CirsOPbO3jU86YKjjMzFLcXIb5YiGLUrjrXFHoJ3e2z9vWiaZVCZQ2+gtRGMPWF+nFhN6AWwLM/juzAQ6KRkbA==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-7.1.2.tgz", + "integrity": "sha512-o96kdRKMKI3E8U0bjnfqW4QMk12MwZ4mhdBTf+B5a1q9+aq2HRnj+3ZdJu0B/ZhJeK78MgYuv6L8d/rA5AeBJA==", "dev": true, "requires": { "ansi-colors": "3.2.3", @@ -1409,9 +1470,9 @@ "growl": "1.10.5", "he": "1.2.0", "js-yaml": "3.13.1", - "log-symbols": "2.2.0", + "log-symbols": "3.0.0", "minimatch": "3.0.4", - "mkdirp": "0.5.1", + "mkdirp": "0.5.5", "ms": "2.1.1", "node-environment-flags": "1.0.6", "object.assign": "4.1.0", @@ -1419,8 +1480,8 @@ "supports-color": "6.0.0", "which": "1.3.1", "wide-align": "1.1.3", - "yargs": "13.3.0", - "yargs-parser": "13.1.1", + "yargs": "13.3.2", + "yargs-parser": "13.1.2", "yargs-unparser": "1.6.0" }, "dependencies": { @@ -1469,21 +1530,6 @@ "path-is-absolute": "^1.0.0" } }, - "minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true - }, - "mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", - "dev": true, - "requires": { - "minimist": "0.0.8" - } - }, "ms": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", @@ -1709,9 +1755,9 @@ "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==" }, "p-limit": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.2.tgz", - "integrity": "sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, "requires": { "p-try": "^2.0.0" @@ -2132,24 +2178,46 @@ "strip-ansi": "^4.0.0" } }, - "string.prototype.trimleft": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz", - "integrity": "sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==", + "string.prototype.trimend": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz", + "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==", "dev": true, "requires": { "define-properties": "^1.1.3", - "function-bind": "^1.1.1" + "es-abstract": "^1.17.5" + } + }, + "string.prototype.trimleft": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz", + "integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5", + "string.prototype.trimstart": "^1.0.0" } }, "string.prototype.trimright": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz", - "integrity": "sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz", + "integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==", "dev": true, "requires": { "define-properties": "^1.1.3", - "function-bind": "^1.1.1" + "es-abstract": "^1.17.5", + "string.prototype.trimend": "^1.0.0" + } + }, + "string.prototype.trimstart": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz", + "integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" } }, "string_decoder": { @@ -2582,9 +2650,9 @@ "dev": true }, "yargs": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.0.tgz", - "integrity": "sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA==", + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", "dev": true, "requires": { "cliui": "^5.0.0", @@ -2596,7 +2664,7 @@ "string-width": "^3.0.0", "which-module": "^2.0.0", "y18n": "^4.0.0", - "yargs-parser": "^13.1.1" + "yargs-parser": "^13.1.2" }, "dependencies": { "ansi-regex": { @@ -2628,21 +2696,13 @@ } }, "yargs-parser": { - "version": "13.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.1.tgz", - "integrity": "sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==", + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", "dev": true, "requires": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" - }, - "dependencies": { - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true - } } }, "yargs-unparser": { diff --git a/package.json b/package.json index adc9874..3bc1031 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@apidevtools/json-schema-ref-parser": "^8.0.0", "@hapi/joi": "^17.1.1", "@types/bcrypt": "^3.0.0", + "@types/express": "^4.17.6", "@types/mocha": "^5.2.7", "@types/mongoose": "^5.7.12", "@types/node": "^13.1.6", @@ -35,7 +36,7 @@ "typescript": "^3.7.4" }, "devDependencies": { - "mocha": "^7.0.0", + "mocha": "^7.1.2", "should": "^13.2.3", "supertest": "^4.0.2" } diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index 60f5b4d..8148d2c 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -236,6 +236,16 @@ describe('/user', () => { }); }); }); + it('rejects a username which is in the special names', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, default: false, + req: {email: 'j.doe@bosch.com', name: 'passreset', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}, + res: {status: 'Username already taken'} + }); + }); it('rejects invalid user details', done => { TestHelper.request(server, done, { method: 'put', @@ -304,6 +314,73 @@ describe('/user', () => { }); }); + describe('DELETE /user/{name}', () => { + it('deletes own user details', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(0); + done(); + }); + }); + }); + it('deletes other user details for admin', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user/janedoe', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + if (err) return done(err); + should(data).have.lengthOf(0); + done(); + }); + }); + }); + it('rejects requests from non-admins for another user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user/admin', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects requests from a user API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects requests from an admin API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user/janedoe', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('returns 404 for an unknown user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user/unknown', + auth: {basic: 'admin'}, + httpStatus: 404 + }); + }); + }); + describe('POST /user/new', () => { it('returns the added user data', done => { TestHelper.request(server, done, { @@ -365,6 +442,16 @@ describe('/user', () => { }); }); }); + it('rejects a username which is in the special names', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 400, default: false, + req: {email: 'j.doe@bosch.com', name: 'passreset', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}, + res: {status: 'Username already taken'} + }); + }); it('rejects invalid user details', done => { TestHelper.request(server, done, { method: 'post', diff --git a/src/routes/user.ts b/src/routes/user.ts index 26f21cc..f4326ea 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -36,7 +36,7 @@ router.get('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi }); router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new - console.log(req.authDetails); + req.params.username = req.params[0]; if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; let username = req.authDetails.username; if (req.params.username !== undefined) { @@ -44,8 +44,6 @@ router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi username = req.params.username; } const {error, value: user} = UserValidate.input(req.body, 'change' + (req.authDetails.level === 'admin'? 'admin' : '')); - console.log(error); - console.log(user); if(error !== undefined) { res.status(400).json({status: 'Invalid body format'}); return; @@ -59,7 +57,7 @@ router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi if (user.hasOwnProperty('name') && user.name !== username) { UserModel.find({name: user.name}).lean().exec( (err, data:any) => { if (err) next(err); - if (data.length > 0) { + if (data.length > 0 || UserValidate.isSpecialName(user.name)) { res.status(400).json({status: 'Username already taken'}); return; } @@ -88,6 +86,26 @@ router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi } }); +router.delete('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new. See https://forbeslindesay.github.io/express-route-tester/ for the generated regex + req.params.username = req.params[0]; + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + let username = req.authDetails.username; + if (req.params.username !== undefined) { + if (!req.auth(res, ['admin'], 'basic')) return; + username = req.params.username; + } + + UserModel.findOneAndDelete({name: username}).lean().exec( (err, data:any) => { + if (err) next(err); + if (data) { + res.json({status: 'OK'}) + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + router.get('/user/key', (req, res, next) => { console.log('hmm'); if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; @@ -111,7 +129,7 @@ router.post('/user/new', (req, res, next) => { // check that user does not already exist UserModel.find({name: user.name}).lean().exec( (err, data:any) => { if (err) next(err); - if (data.length > 0) { + if (data.length > 0 || UserValidate.isSpecialName(user.name)) { res.status(400).json({status: 'Username already taken'}); return; } diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index 68f743d..4f563c6 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -25,6 +25,8 @@ export default class UserValidate { // validate input for user .allow('') }; + private static specialUsernames = ['admin', 'user', 'key', 'new', 'passreset']; // names a user cannot take + static input (data, param) { if (param === 'new') { return joi.object({ @@ -71,4 +73,8 @@ export default class UserValidate { // validate input for user }).validate(data, {stripUnknown: true}) return error !== undefined? null : value; } + + static isSpecialName (name) { // true if name belongs to special names + return this.specialUsernames.indexOf(name) > -1; + } } From 8b355d1d6570c2d46ac667a57ac6f873c6e39f8b Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 27 Apr 2020 15:24:17 +0200 Subject: [PATCH 11/83] added custom type definitions --- package-lock.json | 11 ---- package.json | 5 +- src/customTypes/express.ts | 116 +++++++++++++++++++++++++++++++++++++ tsconfig.json | 6 +- 4 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 src/customTypes/express.ts diff --git a/package-lock.json b/package-lock.json index 9b82ecb..839b669 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,17 +128,6 @@ "@types/node": "*" } }, - "@types/express": { - "version": "4.17.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.6.tgz", - "integrity": "sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w==", - "requires": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "*", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, "@types/express-serve-static-core": { "version": "4.17.5", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.5.tgz", diff --git a/package.json b/package.json index 3bc1031..4753647 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,13 @@ "@apidevtools/json-schema-ref-parser": "^8.0.0", "@hapi/joi": "^17.1.1", "@types/bcrypt": "^3.0.0", - "@types/express": "^4.17.6", + "@types/body-parser": "^1.19.0", + "@types/express-serve-static-core": "^4.17.5", "@types/mocha": "^5.2.7", "@types/mongoose": "^5.7.12", "@types/node": "^13.1.6", + "@types/qs": "^6.9.1", + "@types/serve-static": "^1.13.3", "axios": "^0.19.2", "basic-auth": "^2.0.1", "bcryptjs": "^2.4.3", diff --git a/src/customTypes/express.ts b/src/customTypes/express.ts new file mode 100644 index 0000000..361c961 --- /dev/null +++ b/src/customTypes/express.ts @@ -0,0 +1,116 @@ +// Type definitions for Express 4.17 +// Project: http://expressjs.com +// Definitions by: Boris Yankov +// China Medical University Hospital +// Puneet Arora +// Dylan Frankland +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped +// TypeScript Version: 2.3 + +/* =================== USAGE =================== + + import * as express from "express"; + var app = express(); + + =============================================== */ + +/// +/// + +import * as bodyParser from "body-parser"; +import serveStatic = require("serve-static"); +import * as core from "express-serve-static-core"; +import * as qs from "qs"; + +/** + * Creates an Express application. The express() function is a top-level function exported by the express module. + */ +declare function e(): core.Express; + +declare namespace e { + /** + * This is a built-in middleware function in Express. It parses incoming requests with JSON payloads and is based on body-parser. + * @since 4.16.0 + */ + var json: typeof bodyParser.json; + + /** + * This is a built-in middleware function in Express. It parses incoming requests with Buffer payloads and is based on body-parser. + * @since 4.17.0 + */ + var raw: typeof bodyParser.raw; + + /** + * This is a built-in middleware function in Express. It parses incoming requests with text payloads and is based on body-parser. + * @since 4.17.0 + */ + var text: typeof bodyParser.text; + + /** + * These are the exposed prototypes. + */ + var application: Application; + var request: Request; + var response: Response; + + /** + * This is a built-in middleware function in Express. It serves static files and is based on serve-static. + */ + var static: typeof serveStatic; + + /** + * This is a built-in middleware function in Express. It parses incoming requests with urlencoded payloads and is based on body-parser. + * @since 4.16.0 + */ + var urlencoded: typeof bodyParser.urlencoded; + + /** + * This is a built-in middleware function in Express. It parses incoming request query parameters. + */ + export function query(options: qs.IParseOptions | typeof qs.parse): Handler; + + export function Router(options?: RouterOptions): core.Router; + + interface RouterOptions { + /** + * Enable case sensitivity. + */ + caseSensitive?: boolean; + + /** + * Preserve the req.params values from the parent router. + * If the parent and the child have conflicting param names, the child’s value take precedence. + * + * @default false + * @since 4.5.0 + */ + mergeParams?: boolean; + + /** + * Enable strict routing. + */ + strict?: boolean; + } + + interface Application extends core.Application { } + interface CookieOptions extends core.CookieOptions { } + interface Errback extends core.Errback { } + interface ErrorRequestHandler

    + extends core.ErrorRequestHandler { } + interface Express extends core.Express { } + interface Handler extends core.Handler { } + interface IRoute extends core.IRoute { } + interface IRouter extends core.IRouter { } + interface IRouterHandler extends core.IRouterHandler { } + interface IRouterMatcher extends core.IRouterMatcher { } + interface MediaType extends core.MediaType { } + interface NextFunction extends core.NextFunction { } + interface Request

    extends core.Request { } + interface RequestHandler

    extends core.RequestHandler { } + interface RequestParamHandler extends core.RequestParamHandler { } + export interface Response extends core.Response { } + interface Router extends core.Router { } + interface Send extends core.Send { } +} + +export = e; diff --git a/tsconfig.json b/tsconfig.json index 304952d..8bbe445 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,11 @@ "outDir": "dist", "sourceMap": true, "esModuleInterop": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "typeRoots": [ + "src/customTypings", + "node_modules/@types" + ] }, "files": [ "./node_modules/@types/node/index.d.ts" From 600385cede329d3d6f0d09b76b4ad7b6a2e87048 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Wed, 29 Apr 2020 12:10:27 +0200 Subject: [PATCH 12/83] added /materials route --- .idea/inspectionProfiles/Project_Default.xml | 1 + oas/oas.yaml => api/api.yaml | 0 {oas => api}/condition.yaml | 38 +- api/material.yaml | 121 ++++++ {oas => api}/measurement.yaml | 38 +- {oas => api}/model.yaml | 34 +- {oas => api}/others.yaml | 6 +- {oas => api}/parameters.yaml | 1 + {oas => api}/responses.yaml | 0 {oas => api}/sample.yaml | 48 +-- {oas => api}/schemas.yaml | 94 ++--- {oas => api}/template.yaml | 88 ++--- {oas => api}/user.yaml | 100 ++--- oas/material.yaml | 67 ---- src/helpers/test.ts | 5 +- src/index.ts | 13 +- src/models/material.ts | 16 + src/routes/material.spec.ts | 387 +++++++++++++++++++ src/routes/material.ts | 59 +++ src/routes/user.spec.ts | 102 +++-- src/routes/user.ts | 3 + src/routes/validate/id.ts | 17 + src/routes/validate/material.ts | 82 ++++ src/routes/validate/user.ts | 31 +- src/test/db.json | 69 +++- static/styles/swagger.css | 0 26 files changed, 1081 insertions(+), 339 deletions(-) rename oas/oas.yaml => api/api.yaml (100%) rename {oas => api}/condition.yaml (51%) create mode 100644 api/material.yaml rename {oas => api}/measurement.yaml (52%) rename {oas => api}/model.yaml (54%) rename {oas => api}/others.yaml (85%) rename {oas => api}/parameters.yaml (79%) rename {oas => api}/responses.yaml (100%) rename {oas => api}/sample.yaml (59%) rename {oas => api}/schemas.yaml (55%) rename {oas => api}/template.yaml (66%) rename {oas => api}/user.yaml (60%) delete mode 100644 oas/material.yaml create mode 100644 src/models/material.ts create mode 100644 src/routes/material.spec.ts create mode 100644 src/routes/material.ts create mode 100644 src/routes/validate/id.ts create mode 100644 src/routes/validate/material.ts create mode 100644 static/styles/swagger.css diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index c947305..7e46df7 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -2,5 +2,6 @@ \ No newline at end of file diff --git a/oas/oas.yaml b/api/api.yaml similarity index 100% rename from oas/oas.yaml rename to api/api.yaml diff --git a/oas/condition.yaml b/api/condition.yaml similarity index 51% rename from oas/condition.yaml rename to api/condition.yaml index cca8ca6..5efa2ac 100644 --- a/oas/condition.yaml +++ b/api/condition.yaml @@ -1,6 +1,6 @@ /condition/{id}: parameters: - - $ref: 'oas.yaml#/components/parameters/Id' + - $ref: 'api.yaml#/components/parameters/Id' get: summary: TODO condition by id description: 'Auth: all, levels: read, write, maintain, dev, admin' @@ -12,15 +12,15 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/Condition' + $ref: 'api.yaml#/components/schemas/Condition' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' put: summary: TODO add/change condition description: 'Auth: basic, levels: write, maintain, dev, admin' @@ -33,24 +33,24 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/Condition' + $ref: 'api.yaml#/components/schemas/Condition' responses: 200: description: condition details content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/Condition' + $ref: 'api.yaml#/components/schemas/Condition' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' delete: summary: TODO delete condition description: 'Auth: basic, levels: write, maintain, dev, admin' @@ -60,14 +60,14 @@ - BasicAuth: [] responses: 200: - $ref: 'oas.yaml#/components/responses/Ok' + $ref: 'api.yaml#/components/responses/Ok' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/api/material.yaml b/api/material.yaml new file mode 100644 index 0000000..c32c1ab --- /dev/null +++ b/api/material.yaml @@ -0,0 +1,121 @@ +/materials: + get: + summary: lists all materials + description: 'Auth: all, levels: read, write, maintain, dev, admin' + tags: + - /material + responses: + 200: + description: all material details + content: + application/json: + schema: + type: array + items: + $ref: 'api.yaml#/components/schemas/Material' + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' + +/material/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + get: + summary: get material details + description: 'Auth: all, levels: read, write, maintain, dev, admin' + tags: + - /material + responses: + 200: + description: material details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Material' + 401: + $ref: 'api.yaml#/components/responses/401' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + put: + summary: TODO change material + description: 'Auth: basic, levels: write, maintain, dev, admin' + tags: + - /material + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Material' + responses: + 200: + description: material details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Material' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + delete: + summary: TODO delete material + description: 'Auth: basic, levels: write, maintain, dev, admin' + tags: + - /material + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/material/new: + post: + summary: TODO add material + description: 'Auth: basic, levels: write, maintain, dev, admin' + tags: + - /material + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Material' + responses: + 200: + description: material details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Material' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/measurement.yaml b/api/measurement.yaml similarity index 52% rename from oas/measurement.yaml rename to api/measurement.yaml index 0b4d5b2..0f86047 100644 --- a/oas/measurement.yaml +++ b/api/measurement.yaml @@ -1,6 +1,6 @@ /measurement/{id}: parameters: - - $ref: 'oas.yaml#/components/parameters/Id' + - $ref: 'api.yaml#/components/parameters/Id' get: summary: TODO measurement values by id description: 'Auth: all, levels: read, write, maintain, dev, admin' @@ -12,15 +12,15 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/Measurement' + $ref: 'api.yaml#/components/schemas/Measurement' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' put: summary: TODO add/change measurement description: 'Auth: basic, levels: write, maintain, dev, admin' @@ -33,24 +33,24 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/Measurement' + $ref: 'api.yaml#/components/schemas/Measurement' responses: 200: description: measurement details content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/Measurement' + $ref: 'api.yaml#/components/schemas/Measurement' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' delete: summary: TODO delete measurement description: 'Auth: basic, levels: write, maintain, dev, admin' @@ -60,14 +60,14 @@ - BasicAuth: [] responses: 200: - $ref: 'oas.yaml#/components/responses/Ok' + $ref: 'api.yaml#/components/responses/Ok' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/model.yaml b/api/model.yaml similarity index 54% rename from oas/model.yaml rename to api/model.yaml index 24df9af..f9c3d72 100644 --- a/oas/model.yaml +++ b/api/model.yaml @@ -1,6 +1,6 @@ /model/{name}: parameters: - - $ref: 'oas.yaml#/components/parameters/Name' + - $ref: 'api.yaml#/components/parameters/Name' get: summary: TODO get model data by name description: 'Auth: all, levels: dev, admin' @@ -15,13 +15,13 @@ type: string format: binary 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' put: summary: TODO add/replace model data by name description: 'Auth: all, levels: dev, admin' @@ -37,17 +37,17 @@ format: binary responses: 200: - $ref: 'oas.yaml#/components/responses/Ok' + $ref: 'api.yaml#/components/responses/Ok' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' delete: summary: TODO delete model data description: 'Auth: basic, levels: dev, admin' @@ -57,14 +57,14 @@ - BasicAuth: [] responses: 200: - $ref: 'oas.yaml#/components/responses/Ok' + $ref: 'api.yaml#/components/responses/Ok' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/others.yaml b/api/others.yaml similarity index 85% rename from oas/others.yaml rename to api/others.yaml index c543797..a953bf8 100644 --- a/oas/others.yaml +++ b/api/others.yaml @@ -16,7 +16,7 @@ type: string example: 'API server up and running!' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' /authorized: get: @@ -38,6 +38,6 @@ type: string example: 'basic' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/parameters.yaml b/api/parameters.yaml similarity index 79% rename from oas/parameters.yaml rename to api/parameters.yaml index 659808f..f370c13 100644 --- a/oas/parameters.yaml +++ b/api/parameters.yaml @@ -4,6 +4,7 @@ Id: required: true schema: type: string + example: 5ea0450ed851c30a90e70894 Name: name: name in: path diff --git a/oas/responses.yaml b/api/responses.yaml similarity index 100% rename from oas/responses.yaml rename to api/responses.yaml diff --git a/oas/sample.yaml b/api/sample.yaml similarity index 59% rename from oas/sample.yaml rename to api/sample.yaml index b84be19..e127d74 100644 --- a/oas/sample.yaml +++ b/api/sample.yaml @@ -10,14 +10,14 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/Samples' + $ref: 'api.yaml#/components/schemas/Samples' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' /sample/{id}: parameters: - - $ref: 'oas.yaml#/components/parameters/Id' + - $ref: 'api.yaml#/components/parameters/Id' get: summary: TODO sample details description: 'Auth: all, levels: read, write, maintain, dev, admin' @@ -29,15 +29,15 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/SampleDetail' + $ref: 'api.yaml#/components/schemas/SampleDetail' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' put: summary: TODO add/change sample description: 'Auth: basic, levels: write, maintain, dev, admin' @@ -50,24 +50,24 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/Sample' + $ref: 'api.yaml#/components/schemas/Sample' responses: 200: description: samples details content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/SampleDetail' + $ref: 'api.yaml#/components/schemas/SampleDetail' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' delete: summary: TODO delete sample description: 'Auth: basic, levels: write, maintain, dev, admin' @@ -77,17 +77,17 @@ - BasicAuth: [] responses: 200: - $ref: 'oas.yaml#/components/responses/Ok' + $ref: 'api.yaml#/components/responses/Ok' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' /sample/notes/fields: get: summary: TODO list all existing field names for custom notes fields @@ -107,6 +107,6 @@ type: number example: 20 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/schemas.yaml b/api/schemas.yaml similarity index 55% rename from oas/schemas.yaml rename to api/schemas.yaml index 4d8a805..62b4690 100644 --- a/oas/schemas.yaml +++ b/api/schemas.yaml @@ -5,12 +5,13 @@ _Id: properties: _id: allOf: - - $ref: 'oas.yaml#/components/schemas/Id' + - $ref: 'api.yaml#/components/schemas/Id' readOnly: true Color: properties: color: type: string + example: black SampleProperties: properties: sample_number: @@ -24,24 +25,24 @@ SampleProperties: Samples: allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - - $ref: 'oas.yaml#/components/schemas/Color' - - $ref: 'oas.yaml#/components/schemas/SampleProperties' + - $ref: 'api.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/Color' + - $ref: 'api.yaml#/components/schemas/SampleProperties' properties: material_id: - $ref: 'oas.yaml#/components/schemas/Id' + $ref: 'api.yaml#/components/schemas/Id' note_id: - $ref: 'oas.yaml#/components/schemas/Id' + $ref: 'api.yaml#/components/schemas/Id' user_id: - $ref: 'oas.yaml#/components/schemas/Id' + $ref: 'api.yaml#/components/schemas/Id' Sample: allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - - $ref: 'oas.yaml#/components/schemas/Color' - - $ref: 'oas.yaml#/components/schemas/SampleProperties' + - $ref: 'api.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/Color' + - $ref: 'api.yaml#/components/schemas/SampleProperties' properties: material: - $ref: 'oas.yaml#/components/schemas/Material' + $ref: 'api.yaml#/components/schemas/Material' notes: type: object properties: @@ -50,15 +51,15 @@ Sample: sample_references: type: array items: - $ref: 'oas.yaml#/components/schemas/Id' + $ref: 'api.yaml#/components/schemas/Id' SampleDetail: allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - - $ref: 'oas.yaml#/components/schemas/Color' - - $ref: 'oas.yaml#/components/schemas/SampleProperties' + - $ref: 'api.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/Color' + - $ref: 'api.yaml#/components/schemas/SampleProperties' properties: material: - $ref: 'oas.yaml#/components/schemas/Material' + $ref: 'api.yaml#/components/schemas/Material' notes: type: object properties: @@ -67,63 +68,70 @@ SampleDetail: sample_references: type: array items: - $ref: 'oas.yaml#/components/schemas/Id' + $ref: 'api.yaml#/components/schemas/Id' conditions: type: array items: - $ref: 'oas.yaml#/components/schemas/Condition' + $ref: 'api.yaml#/components/schemas/Condition' Material: allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/_Id' properties: - material_numbers: + name: + type: string + example: Stanyl TW 200 F8 + supplier: + type: string + example: DSM + group: + type: string + example: PA46 + mineral: + type: number + example: 0 + glass_fiber: + type: number + example: 40 + carbon_fiber: + type: number + example: 0 + numbers: type: array items: type: object allOf: - - $ref: 'oas.yaml#/components/schemas/Color' + - $ref: 'api.yaml#/components/schemas/Color' properties: number: type: number - material_group: - type: string - supplier: - type: string - material_name: - type: string - mineral: - type: number - glass_fiber: - type: number - carbon_fiber: - type: number + example: 5514263423 Condition: allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/_Id' properties: sample_id: - $ref: 'oas.yaml#/components/schemas/Id' + $ref: 'api.yaml#/components/schemas/Id' parameters: type: object treatment_template: - $ref: 'oas.yaml#/components/schemas/Id' + $ref: 'api.yaml#/components/schemas/Id' Measurement: allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/_Id' properties: condition_id: - $ref: 'oas.yaml#/components/schemas/Id' + $ref: 'api.yaml#/components/schemas/Id' values: type: object measurement_template: - $ref: 'oas.yaml#/components/schemas/Id' + $ref: 'api.yaml#/components/schemas/Id' Template: allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/_Id' properties: name: type: string @@ -149,9 +157,9 @@ UserName: example: johndoe User: allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - - $ref: 'oas.yaml#/components/schemas/UserName' - - $ref: 'oas.yaml#/components/schemas/Email' + - $ref: 'api.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/UserName' + - $ref: 'api.yaml#/components/schemas/Email' properties: pass: type: string diff --git a/oas/template.yaml b/api/template.yaml similarity index 66% rename from oas/template.yaml rename to api/template.yaml index a09cb21..9219d81 100644 --- a/oas/template.yaml +++ b/api/template.yaml @@ -14,7 +14,7 @@ schema: type: array items: - $ref: 'oas.yaml#/components/schemas/Template' + $ref: 'api.yaml#/components/schemas/Template' example: name: heat aging parameters: @@ -22,12 +22,12 @@ range: - copper 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' /templates/treatment/{name}: parameters: - - $ref: 'oas.yaml#/components/parameters/Name' + - $ref: 'api.yaml#/components/parameters/Name' get: summary: TODO treatment method details description: 'Auth: basic, levels: read, write, maintain, admin' @@ -42,7 +42,7 @@ application/json: schema: allOf: - - $ref: 'oas.yaml#/components/schemas/Template' + - $ref: 'api.yaml#/components/schemas/Template' example: name: heat aging parameters: @@ -50,13 +50,13 @@ range: - copper 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' put: summary: TODO add/change treatment method description: 'Auth: basic, levels: maintain, admin' @@ -70,7 +70,7 @@ application/json: schema: allOf: - - $ref: 'oas.yaml#/components/schemas/Template' + - $ref: 'api.yaml#/components/schemas/Template' example: name: heat aging parameters: @@ -84,7 +84,7 @@ application/json: schema: allOf: - - $ref: 'oas.yaml#/components/schemas/Template' + - $ref: 'api.yaml#/components/schemas/Template' example: name: heat aging parameters: @@ -92,15 +92,15 @@ range: - copper 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' delete: summary: TODO delete treatment method description: 'Auth: basic, levels: maintain, admin' @@ -110,17 +110,17 @@ - BasicAuth: [] responses: 200: - $ref: 'oas.yaml#/components/responses/Ok' + $ref: 'api.yaml#/components/responses/Ok' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' /template/measurements: get: summary: TODO all available measurement methods @@ -137,7 +137,7 @@ schema: type: array items: - $ref: 'oas.yaml#/components/schemas/Template' + $ref: 'api.yaml#/components/schemas/Template' example: name: humidity parameters: @@ -146,12 +146,12 @@ min: 0 max: 2 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' /templates/measurement/{name}: parameters: - - $ref: 'oas.yaml#/components/parameters/Name' + - $ref: 'api.yaml#/components/parameters/Name' get: summary: TODO measurement method details description: 'Auth: basic, levels: read, write, maintain, admin' @@ -166,7 +166,7 @@ application/json: schema: allOf: - - $ref: 'oas.yaml#/components/schemas/Template' + - $ref: 'api.yaml#/components/schemas/Template' example: name: humidity parameters: @@ -175,13 +175,13 @@ min: 0 max: 2 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' put: summary: TODO add/change measurement method description: 'Auth: basic, levels: maintain, admin' @@ -195,7 +195,7 @@ application/json: schema: allOf: - - $ref: 'oas.yaml#/components/schemas/Template' + - $ref: 'api.yaml#/components/schemas/Template' example: name: humidity parameters: @@ -210,7 +210,7 @@ application/json: schema: allOf: - - $ref: 'oas.yaml#/components/schemas/Template' + - $ref: 'api.yaml#/components/schemas/Template' example: name: humidity parameters: @@ -219,15 +219,15 @@ min: 0 max: 2 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' delete: summary: TODO delete measurement method description: 'Auth: basic, levels: maintain, admin' @@ -237,14 +237,14 @@ - BasicAuth: [] responses: 200: - $ref: 'oas.yaml#/components/responses/Ok' + $ref: 'api.yaml#/components/responses/Ok' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/user.yaml b/api/user.yaml similarity index 60% rename from oas/user.yaml rename to api/user.yaml index 7d9d225..757ebf0 100644 --- a/oas/user.yaml +++ b/api/user.yaml @@ -14,13 +14,13 @@ schema: type: array items: - $ref: 'oas.yaml#/components/schemas/User' + $ref: 'api.yaml#/components/schemas/User' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' /user: get: summary: list own user details @@ -35,13 +35,13 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/User' + $ref: 'api.yaml#/components/schemas/User' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' put: summary: change user details description: 'Auth: basic, levels: read, write, maintain, admin' @@ -55,9 +55,9 @@ application/json: schema: allOf: - - $ref: 'oas.yaml#/components/schemas/_Id' - - $ref: 'oas.yaml#/components/schemas/UserName' - - $ref: 'oas.yaml#/components/schemas/Email' + - $ref: 'api.yaml#/components/schemas/_Id' + - $ref: 'api.yaml#/components/schemas/UserName' + - $ref: 'api.yaml#/components/schemas/Email' properties: pass: type: string @@ -75,15 +75,15 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/User' + $ref: 'api.yaml#/components/schemas/User' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' delete: summary: delete user description: 'Auth: basic, levels: read, write, maintain, admin' @@ -93,14 +93,14 @@ - BasicAuth: [] responses: 200: - $ref: 'oas.yaml#/components/responses/Ok' + $ref: 'api.yaml#/components/responses/Ok' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' /user/{name}: parameters: - - $ref: 'oas.yaml#/components/parameters/Name' + - $ref: 'api.yaml#/components/parameters/Name' get: summary: list user details description: 'Auth: basic, levels: admin' @@ -114,15 +114,15 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/User' + $ref: 'api.yaml#/components/schemas/User' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' put: summary: change user details description: 'Auth: basic, levels: admin' @@ -135,24 +135,24 @@ content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/User' + $ref: 'api.yaml#/components/schemas/User' responses: 200: description: user details content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/User' + $ref: 'api.yaml#/components/schemas/User' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' delete: summary: delete user description: 'Auth: basic, levels: admin' @@ -162,15 +162,15 @@ - BasicAuth: [] responses: 200: - $ref: 'oas.yaml#/components/responses/Ok' + $ref: 'api.yaml#/components/responses/Ok' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' /user/key: get: summary: get API key for the user @@ -190,9 +190,9 @@ type: string example: 5ea0450ed851c30a90e70899 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' /user/new: post: summary: add new user @@ -214,22 +214,22 @@ - location - device_name allOf: - - $ref: 'oas.yaml#/components/schemas/User' + - $ref: 'api.yaml#/components/schemas/User' responses: 200: description: user details content: application/json: schema: - $ref: 'oas.yaml#/components/schemas/User' + $ref: 'api.yaml#/components/schemas/User' 400: - $ref: 'oas.yaml#/components/responses/400' + $ref: 'api.yaml#/components/responses/400' 401: - $ref: 'oas.yaml#/components/responses/401' + $ref: 'api.yaml#/components/responses/401' 403: - $ref: 'oas.yaml#/components/responses/403' + $ref: 'api.yaml#/components/responses/403' 500: - $ref: 'oas.yaml#/components/responses/500' + $ref: 'api.yaml#/components/responses/500' /user/passreset: post: summary: reset password and send mail to restore @@ -244,12 +244,12 @@ application/json: schema: allOf: - - $ref: 'oas.yaml#/components/schemas/UserName' - - $ref: 'oas.yaml#/components/schemas/Email' + - $ref: 'api.yaml#/components/schemas/UserName' + - $ref: 'api.yaml#/components/schemas/Email' responses: 200: - $ref: 'oas.yaml#/components/responses/Ok' + $ref: 'api.yaml#/components/responses/Ok' 404: - $ref: 'oas.yaml#/components/responses/404' + $ref: 'api.yaml#/components/responses/404' 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/oas/material.yaml b/oas/material.yaml deleted file mode 100644 index d5d7d34..0000000 --- a/oas/material.yaml +++ /dev/null @@ -1,67 +0,0 @@ -/material/{id}: - parameters: - - $ref: 'oas.yaml#/components/parameters/Id' - get: - summary: TODO get material details - description: 'Auth: all, levels: read, write, maintain, dev, admin' - tags: - - /material - responses: - 200: - description: created material - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Material' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 500: - $ref: 'oas.yaml#/components/responses/500' - put: - summary: TODO add/change material - description: 'Auth: basic, levels: write, maintain, dev, admin' - tags: - - /material - security: - - BasicAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Material' - responses: - 200: - description: material details - content: - application/json: - schema: - $ref: 'oas.yaml#/components/schemas/Material' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 500: - $ref: 'oas.yaml#/components/responses/500' - delete: - summary: TODO delete material - description: 'Auth: basic, levels: write, maintain, dev, admin' - tags: - - /material - security: - - BasicAuth: [] - responses: - 200: - $ref: 'oas.yaml#/components/responses/Ok' - 400: - $ref: 'oas.yaml#/components/responses/400' - 401: - $ref: 'oas.yaml#/components/responses/401' - 403: - $ref: 'oas.yaml#/components/responses/403' - 500: - $ref: 'oas.yaml#/components/responses/500' \ No newline at end of file diff --git a/src/helpers/test.ts b/src/helpers/test.ts index 4a537aa..afd49dd 100644 --- a/src/helpers/test.ts +++ b/src/helpers/test.ts @@ -5,8 +5,9 @@ import db from "../db"; export default class TestHelper { public static auth = { - admin: {pass: 'Abc123!#', key: '5ea131671feb9c2ee0aafc9a'}, - janedoe: {pass: 'Xyz890*)', key: '5ea0450ed851c30a90e70899'} + admin: {pass: 'Abc123!#', key: '000000000000000000001003'}, + janedoe: {pass: 'Xyz890*)', key: '000000000000000000001002'}, + user: {pass: 'Xyz890*)', key: '000000000000000000001001'} } public static res = { 400: {status: 'Bad request'}, diff --git a/src/index.ts b/src/index.ts index 67e29e1..e8cf691 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,16 +46,17 @@ app.use(require('./helpers/authorize')); // handle authentication // require routes app.use('/', require('./routes/root')); app.use('/', require('./routes/user')); +app.use('/', require('./routes/material')); // Swagger UI -let oasDoc: JSONSchema = {}; -jsonRefParser.bundle('oas/oas.yaml', (err, doc) => { +let apiDoc: JSONSchema = {}; +jsonRefParser.bundle('api/api.yaml', (err, doc) => { if(err) throw err; - oasDoc = doc; - oasDoc.paths = oasDoc.paths.allOf.reduce((s, e) => Object.assign(s, e)); - swagger.setup(oasDoc, {defaultModelsExpandDepth: -1, customCss: '.swagger-ui .topbar { display: none }'}); + apiDoc = doc; + apiDoc.paths = apiDoc.paths.allOf.reduce((s, e) => Object.assign(s, e)); + swagger.setup(apiDoc, {defaultModelsExpandDepth: -1, customCss: '.swagger-ui .topbar { display: none }'}); }); -app.use('/api', swagger.serve, swagger.setup(oasDoc, {defaultModelsExpandDepth: -1, customCss: '.swagger-ui .topbar { display: none }'})); +app.use('/api', swagger.serve, swagger.setup(apiDoc, {defaultModelsExpandDepth: -1, customCss: '.swagger-ui .topbar { display: none }'})); app.use((req, res) => { // 404 error handling res.status(404).json({status: 'Not found'}); diff --git a/src/models/material.ts b/src/models/material.ts new file mode 100644 index 0000000..530f8f0 --- /dev/null +++ b/src/models/material.ts @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; + +const MaterialSchema = new mongoose.Schema({ + name: {type: String, index: {unique: true}}, + supplier: String, + group: String, + mineral: String, + glass_fiber: String, + carbon_fiber: String, + numbers: [{ + color: String, + number: Number + }] +}); + +export default mongoose.model('material', MaterialSchema); \ No newline at end of file diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts new file mode 100644 index 0000000..bb82bc1 --- /dev/null +++ b/src/routes/material.spec.ts @@ -0,0 +1,387 @@ +import should from 'should/as-function'; +import MaterialModel from '../models/material'; +import TestHelper from "../helpers/test"; + + +describe('/material', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + + describe('GET /materials', () => { + it('returns all materials', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.users.length); + should(res.body).matchEach(material => { + should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(material).have.property('_id').be.type('string'); + should(material).have.property('name').be.type('string'); + should(material).have.property('supplier').be.type('string'); + should(material).have.property('group').be.type('string'); + should(material).have.property('mineral').be.type('number'); + should(material).have.property('glass_fiber').be.type('number'); + should(material).have.property('carbon_fiber').be.type('number'); + should(material.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color').be.type('string'); + should(number).have.property('number').be.type('number'); + }); + }); + done(); + }); + }); + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials', + auth: {key: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.users.length); + should(res.body).matchEach(material => { + should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(material).have.property('_id').be.type('string'); + should(material).have.property('name').be.type('string'); + should(material).have.property('supplier').be.type('string'); + should(material).have.property('group').be.type('string'); + should(material).have.property('mineral').be.type('number'); + should(material).have.property('glass_fiber').be.type('number'); + should(material).have.property('carbon_fiber').be.type('number'); + should(material.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color').be.type('string'); + should(number).have.property('number').be.type('number'); + }); + }); + done(); + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials', + httpStatus: 401 + }); + }); + }); + + describe('GET /material/{id}', () => { + it('returns the right material', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}]} + }); + }); + it('returns the right material for an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000003', + auth: {key: 'admin'}, + httpStatus: 200, + res: {_id: '100000000000000000000003', name: 'PA GF 50 black (2706)', supplier: 'Akro-Plastic', group: 'PA66+PA6I/6T', mineral: 0, glass_fiber: 0, carbon_fiber: 0, numbers: []} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/10000000000000000000000x', + auth: {key: 'admin'}, + httpStatus: 404 + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000111', + auth: {key: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000001', + httpStatus: 401 + }); + }); + }); + + describe('PUT /material/{id}', () => { + it('returns the right material', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {}, + res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}]} + }); + }); + it('changes the given properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}]} + , + }).end(err => { + if (err) return done(err); + MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).be.eql({_id: '100000000000000000000002', name: 'UltramidTKR4355G7', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}], __v: 0} + ); + done(); + }); + }); + }); + it('rejects already existing material names', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'UltramidTKR4355G7'}, + res: {status: 'Material name already taken'} + }); + }); + it('rejects wrong material properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {mineral: 'x', glass_fiber: 'x', carbon_fiber: 'x', numbers: [{colorxx: 'black', number: 'xxx'}]}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/10000000000000000000000x', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {}, + res: {status: 'Invalid id'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000002', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000002', + auth: {basic: 'user'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown material', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000111', + auth: {basic: 'janedoe'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('DELETE /material/{id}', () => { + it('deletes the material', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(0); + done(); + }); + }); + }); + it('rejects deleting a material referenced by samples'); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/10000000000000000000000x', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid id'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000002', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000002', + auth: {basic: 'user'}, + httpStatus: 403 + }); + }); + it('returns 404 for an unknown id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000111', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000001', + httpStatus: 401 + }); + }); + }); + + describe('POST /material/new', () => { + it('returns the right material', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]} + }).end((err, res) => { + if (err) return done (err); + console.log(res.body); + should(res.body).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('name', 'Crastin CE 2510'); + should(res.body).have.property('supplier', 'Du Pont'); + should(res.body).have.property('group', 'PBT'); + should(res.body).have.property('mineral', 0); + should(res.body).have.property('glass_fiber', 30); + should(res.body).have.property('carbon_fiber', 0); + should(res.body.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color', 'black'); + should(number).have.property('number', 5515798402); + }); + done(); + }); + }); + it('stores the material', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []} + }).end(err => { + if (err) return done (err); + MaterialModel.find({name: 'Crastin CE 2510'}).lean().exec((err, data: any) => { + if (err) return done (err); + console.log(data[0]); + should(data[0]).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', '__v'); + should(data[0]).have.property('_id'); + should(data[0]).have.property('name', 'Crastin CE 2510'); + should(data[0]).have.property('supplier', 'Du Pont'); + should(data[0]).have.property('group', 'PBT'); + should(data[0]).have.property('mineral', '0'); + should(data[0]).have.property('glass_fiber', '30'); + should(data[0]).have.property('carbon_fiber', '0'); + should(data[0].numbers).have.lengthOf(0); + done(); + }); + }); + }); + it('rejects already existing material names', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}]}, + res: {status: 'Material name already taken'} + }); + }); + it('rejects wrong material properties', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 'x', glass_fiber: 'x', carbon_fiber: 'x', numbers: [{colorxx: 'black', number: 'xxx'}]}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects incomplete material properties', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510'}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'user'}, + httpStatus: 403, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + httpStatus: 401, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []} + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/material.ts b/src/routes/material.ts new file mode 100644 index 0000000..42911e3 --- /dev/null +++ b/src/routes/material.ts @@ -0,0 +1,59 @@ +import express from 'express'; + +import MaterialValidate from './validate/material'; +import MaterialModel from '../models/material' +import IdValidate from './validate/id'; + + +const router = express.Router(); + +router.get('/materials', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + MaterialModel.find({}).lean().exec((err, data) => { + if(err) next(err); + res.json(data.map(e => MaterialValidate.output(e)).filter(e => e !== null)); // validate all and filter null values from validation errors + }); +}); + +router.get('/material/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + MaterialModel.findById(req.params.id).lean().exec((err, data) => { + if(err) next(err); + console.log(data); + if (data) { + res.json(MaterialValidate.output(data)); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + +router.post('/material/new', (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + // validate input + const {error, value: material} = MaterialValidate.input(req.body, 'new'); + if(error !== undefined) { + res.status(400).json({status: 'Invalid body format'}); + return; + } + + MaterialModel.find({name: material.name}).lean().exec((err, data) => { + if(err) next(err); + if (data.length > 0) { + res.status(400).json({status: 'Material name already taken'}); + return; + } + + new MaterialModel(material).save((err, data) => { + if(err) next(err); + res.json(MaterialValidate.output(data.toObject())); + }); + }) +}) + + +module.exports = router; \ No newline at end of file diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index 8148d2c..b103ef7 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -48,6 +48,13 @@ describe('/user', () => { httpStatus: 401 }); }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/users', + httpStatus: 401 + }); + }); }); describe('GET /user/{name}', () => { @@ -119,6 +126,13 @@ describe('/user', () => { httpStatus: 404 }); }); + it('rejects requests from an admin API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/janedoe', + httpStatus: 401 + }); + }); }); describe('PUT /user/{name}', () => { @@ -169,7 +183,7 @@ describe('/user', () => { req: {name: 'adminnew', email: 'adminnew@bosch.com', pass: 'Abc123##', location: 'Abt', device_name: 'test'} }).end(err => { if (err) return done (err); - UserModel.find({name: 'adminnew'}).lean().exec( 'find', (err, data) => { + UserModel.find({name: 'adminnew'}).lean().exec( (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'); @@ -193,7 +207,7 @@ describe('/user', () => { req: {level: 'read'} }).end(err => { if (err) return done (err); - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { if (err) return done(err); should(data).have.lengthOf(1); should(data[0]).have.property('level', 'read'); @@ -211,7 +225,7 @@ describe('/user', () => { }).end((err, res) => { if (err) return done (err); should(res.body).be.eql({status: 'Invalid body format'}); - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { if (err) return done(err); should(data).have.lengthOf(1); should(data[0]).have.property('level', 'write'); @@ -229,7 +243,7 @@ describe('/user', () => { }).end((err, res) => { if (err) return done (err); should(res.body).be.eql({status: 'Username already taken'}); - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { if (err) return done(err); should(data).have.lengthOf(1); done(); @@ -312,6 +326,14 @@ describe('/user', () => { req: {} }); }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user/janedoe', + httpStatus: 401, + req: {} + }); + }); }); describe('DELETE /user/{name}', () => { @@ -324,7 +346,7 @@ describe('/user', () => { }).end((err, res) => { if (err) return done (err); should(res.body).be.eql({status: 'OK'}); - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { if (err) return done(err); should(data).have.lengthOf(0); done(); @@ -340,7 +362,7 @@ describe('/user', () => { }).end((err, res) => { if (err) return done (err); should(res.body).be.eql({status: 'OK'}); - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { if (err) return done(err); should(data).have.lengthOf(0); done(); @@ -379,6 +401,40 @@ describe('/user', () => { httpStatus: 404 }); }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user/janedoe', + httpStatus: 401 + }); + }); + }); + + describe('GET /user/key', () => { + it('returns the right API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/key', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {key: TestHelper.auth.janedoe.key} + }); + }); + it('rejects requests from an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/key', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects requests from an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/user/key', + httpStatus: 401 + }); + }); }); describe('POST /user/new', () => { @@ -410,7 +466,7 @@ describe('/user', () => { req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} }).end(err => { if (err) return done (err); - UserModel.find({name: 'johndoe'}).lean().exec( 'find', (err, data) => { + UserModel.find({name: 'johndoe'}).lean().exec( (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'); @@ -435,7 +491,7 @@ describe('/user', () => { }).end((err, res) => { if (err) return done (err); should(res.body).be.eql({status: 'Username already taken'}); - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data) => { + UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { if (err) return done(err); should(data).have.lengthOf(1); done(); @@ -510,6 +566,14 @@ describe('/user', () => { req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} }); }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + httpStatus: 401, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'} + }); + }); }); describe('POST /user/passreset', () => { @@ -539,7 +603,7 @@ describe('/user', () => { }); }); it('changes the user password', done => { - UserModel.find({name: 'janedoe'}).lean().exec( 'find', (err, data: any) => { + UserModel.find({name: 'janedoe'}).lean().exec( (err, data: any) => { if (err) return done(err); const oldpass = data[0].pass; TestHelper.request(server, done, { @@ -559,24 +623,4 @@ describe('/user', () => { }); }); }); - - describe('GET /user/key', () => { - it('returns the right API key', done => { - TestHelper.request(server, done, { - method: 'get', - url: '/user/key', - auth: {basic: 'janedoe'}, - httpStatus: 200, - res: {key: TestHelper.auth.janedoe.key} - }); - }); - it('rejects requests from an API key', done => { - TestHelper.request(server, done, { - method: 'get', - url: '/user/key', - auth: {key: 'janedoe'}, - httpStatus: 401 - }); - }); - }) }); \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts index f4326ea..aabb8a2 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -1,12 +1,14 @@ import express from 'express'; import mongoose from 'mongoose'; import bcrypt from 'bcryptjs'; + import UserValidate from './validate/user'; import UserModel from '../models/user'; import mail from '../helpers/mail'; const router = express.Router(); + router.get('/users', (req, res) => { if (!req.auth(res, ['admin'], 'basic')) return; @@ -168,4 +170,5 @@ router.post('/user/passreset', (req, res, next) => { }); }); + module.exports = router; \ No newline at end of file diff --git a/src/routes/validate/id.ts b/src/routes/validate/id.ts new file mode 100644 index 0000000..84024e9 --- /dev/null +++ b/src/routes/validate/id.ts @@ -0,0 +1,17 @@ +import joi from '@hapi/joi'; + +export default class IdValidate { + private static id = joi.string().pattern(new RegExp('[0-9a-f]{24}')).length(24); + + static get () { + return this.id; + } + + static valid (id) { + return this.id.validate(id).error === undefined; + } + + static parameter() { // :id url parameter + return ':id([0-9a-f]{24})'; + } +} \ No newline at end of file diff --git a/src/routes/validate/material.ts b/src/routes/validate/material.ts new file mode 100644 index 0000000..c5ac005 --- /dev/null +++ b/src/routes/validate/material.ts @@ -0,0 +1,82 @@ +import joi from '@hapi/joi'; + +import IdValidate from './id'; + +export default class MaterialValidate { // validate input for material + private static material = { + name: joi.string() + .max(128), + + supplier: joi.string() + .max(128), + + group: joi.string() + .max(128), + + mineral: joi.number() + .integer() + .min(0) + .max(100), + + glass_fiber: joi.number() + .integer() + .min(0) + .max(100), + + carbon_fiber: joi.number() + .integer() + .min(0) + .max(100), + + numbers: joi.array() + .items(joi.object({ + color: joi.string() + .max(128), + number: joi.number() + .min(0) + })) + }; + + static input (data, param) { // validate data, param: new(everything required)/change(available attributes are validated) + if (param === 'new') { + return joi.object({ + name: this.material.name.required(), + supplier: this.material.supplier.required(), + group: this.material.group.required(), + mineral: this.material.mineral.required(), + glass_fiber: this.material.glass_fiber.required(), + carbon_fiber: this.material.carbon_fiber.required(), + numbers: this.material.numbers + }).validate(data); + } + else if (param === 'change') { + return joi.object({ + name: this.material.name, + supplier: this.material.supplier, + group: this.material.group, + mineral: this.material.mineral, + glass_fiber: this.material.glass_fiber, + carbon_fiber: this.material.carbon_fiber, + numbers: this.material.numbers + }).validate(data); + } + else { + return{error: 'No parameter specified!', value: {}}; + } + } + + static output (data) { // validate output from database for needed properties, strip everything else + data._id = data._id.toString(); + const {value, error} = joi.object({ + _id: IdValidate.get(), + name: this.material.name, + supplier: this.material.supplier, + group: this.material.group, + mineral: this.material.mineral, + glass_fiber: this.material.glass_fiber, + carbon_fiber: this.material.carbon_fiber, + numbers: this.material.numbers + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index 4f563c6..4b1259a 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -1,28 +1,34 @@ import joi from '@hapi/joi'; import globals from '../../globals'; +import IdValidate from './id'; + export default class UserValidate { // validate input for user private static user = { - _id: joi.any(), name: joi.string() .alphanum() - .lowercase(), + .lowercase() + .max(128), email: joi.string() .email({minDomainSegments: 2}) - .lowercase(), + .lowercase() + .max(128), pass: joi.string() - .pattern(new RegExp('^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$')), + .pattern(new RegExp('^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$')) + .max(128), level: joi.string() .valid(...globals.levels), location: joi.string() - .alphanum(), + .alphanum() + .max(128), device_name: joi.string() .allow('') + .max(128), }; private static specialUsernames = ['admin', 'user', 'key', 'new', 'passreset']; // names a user cannot take @@ -63,14 +69,15 @@ export default class UserValidate { // validate input for user } static output (data) { // validate output from database for needed properties, strip everything else + data._id = data._id.toString(); const {value, error} = joi.object({ - _id: joi.any(), - name: joi.string(), - email: joi.string(), - level: joi.string(), - location: joi.string(), - device_name: joi.string().allow('') - }).validate(data, {stripUnknown: true}) + _id: IdValidate.get(), + name: this.user.name, + email: this.user.email, + level: this.user.level, + location: this.user.location, + device_name: this.user.device_name + }).validate(data, {stripUnknown: true}); return error !== undefined? null : value; } diff --git a/src/test/db.json b/src/test/db.json index 4c2c645..0b4fd2f 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -2,27 +2,88 @@ "collections": { "users": [ { - "_id": {"$oid":"5ea0450ed851c30a90e70894"}, + "_id": {"$oid":"000000000000000000000001"}, + "email": "user@bosch.com", + "name": "user", + "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi", + "level": "read", + "location": "Rng", + "device_name": "Alpha I", + "key": "000000000000000000001001", + "__v": 0 + }, + { + "_id": {"$oid":"000000000000000000000002"}, "email": "jane.doe@bosch.com", "name": "janedoe", "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi", "level": "write", "location": "Rng", "device_name": "Alpha I", - "key": "5ea0450ed851c30a90e70899", + "key": "000000000000000000001002", "__v": 0 }, { - "_id": {"$oid":"5ea131671feb9c2ee0aafc9b"}, + "_id": {"$oid":"000000000000000000000003"}, "email": "a.d.m.i.n@bosch.com", "name": "admin", "pass": "$2a$10$i872o3qR5V3JnbDArD8Z.eDo.BNPDBaR7dUX9KSEtl9pUjLyucy2K", "level": "admin", "location": "Rng", "device_name": "", - "key": "5ea131671feb9c2ee0aafc9a", + "key": "000000000000000000001003", "__v": "0" } + ], + "materials": [ + { + "_id": {"$oid":"100000000000000000000001"}, + "name": "Stanyl TW 200 F8", + "supplier": "DSM", + "group": "PA46", + "mineral": 0, + "glass_fiber": 40, + "carbon_fiber": 0, + "numbers": [ + { + "color": "black", + "number": 5514263423 + } + ], + "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000002"}, + "name": "Ultramid T KR 4355 G7", + "supplier": "BASF", + "group": "PA6/6T", + "mineral": 0, + "glass_fiber": 35, + "carbon_fiber": 0, + "numbers": [ + { + "color": "black", + "number": 5514212901 + }, + { + "color": "signalviolet", + "number": 5514612901 + } + ], + "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000003"}, + "name": "PA GF 50 black (2706)", + "supplier": "Akro-Plastic", + "group": "PA66+PA6I/6T", + "mineral": 0, + "glass_fiber": 0, + "carbon_fiber": 0, + "numbers": [ + ], + "__v": 0 + } ] } } \ No newline at end of file diff --git a/static/styles/swagger.css b/static/styles/swagger.css new file mode 100644 index 0000000..e69de29 From 2f0d2f2276168c0137cd369c8d39dce837b1508d Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Wed, 29 Apr 2020 15:07:07 +0200 Subject: [PATCH 13/83] styled swagger --- src/index.ts | 5 +- static/img/bosch-logo.svg | 201 ++++++++++++++++++++++++++++ static/styles/swagger.css | 271 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 static/img/bosch-logo.svg diff --git a/src/index.ts b/src/index.ts index e8cf691..f79a554 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,9 @@ app.use('/', require('./routes/root')); app.use('/', require('./routes/user')); app.use('/', require('./routes/material')); +// static files +app.use('/static', express.static('static')); + // Swagger UI let apiDoc: JSONSchema = {}; jsonRefParser.bundle('api/api.yaml', (err, doc) => { @@ -56,7 +59,7 @@ jsonRefParser.bundle('api/api.yaml', (err, doc) => { apiDoc.paths = apiDoc.paths.allOf.reduce((s, e) => Object.assign(s, e)); swagger.setup(apiDoc, {defaultModelsExpandDepth: -1, customCss: '.swagger-ui .topbar { display: none }'}); }); -app.use('/api', swagger.serve, swagger.setup(apiDoc, {defaultModelsExpandDepth: -1, customCss: '.swagger-ui .topbar { display: none }'})); +app.use('/api', swagger.serve, swagger.setup(apiDoc, {customCssUrl: '/static/styles/swagger.css'})); app.use((req, res) => { // 404 error handling res.status(404).json({status: 'Not found'}); diff --git a/static/img/bosch-logo.svg b/static/img/bosch-logo.svg new file mode 100644 index 0000000..fae963f --- /dev/null +++ b/static/img/bosch-logo.svg @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/styles/swagger.css b/static/styles/swagger.css index e69de29..b86a643 100644 --- a/static/styles/swagger.css +++ b/static/styles/swagger.css @@ -0,0 +1,271 @@ +/*Bosch styling for swagger*/ + +/*GET: dark blue*/ +/*POST: dark green*/ +/*PUT: turquoise*/ +/*DELETE: fuchsia*/ + +:root { + --red: #ea0016; + --dark-blue: #005691; + --dark-blue-w75: #bfd5e3; + --dark-green: #006249; + --dark-green-w75: #bfd8d1; + --turquoise: #00a8b0; + --turquoise-w75: #bfe9eb; + --fuchsia: #b90276; + --fuchsia-w75: #edc0dd; + --light-grey: #bfc0c2; + --light-grey-w75: #efeff0; + --light-green: #78be20; +} + +body { + background: #fff; +} + +body:before { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 16px; + content: ''; + background-repeat: no-repeat; + background-size: cover; + background-image: url(data:image/svg+xml;base64,PHN2ZwogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICB4bWw6c3BhY2U9InByZXNlcnZlIgogIGhlaWdodD0iMzAwIgogIHdpZHRoPSI3MjAiCiAgdmVyc2lvbj0iMS4xIgogIHk9IjAiCiAgeD0iMCIKICB2aWV3Qm94PSIwIDAgNzIwIDMwMCI+CiAgPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KICAgIC5zdDAgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF8xXyIpOwogICAgfQogICAgLnN0MSB7CiAgICAgIGZpbGw6IHVybCgiI1NWR0lEXzJfIik7CiAgICB9CiAgICAuc3QyIHsKICAgICAgZmlsbDogdXJsKCIjU1ZHSURfM18iKTsKICAgIH0KICAgIC5zdDMgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF80XyIpOwogICAgfQogICAgLnN0NCB7CiAgICAgIGZpbGw6IHVybCgiI1NWR0lEXzVfIik7CiAgICB9CiAgICAuc3Q1IHsKICAgICAgZmlsbDogI0FGMjAyNDsKICAgIH0KICAgIC5zdDYgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF82XyIpOwogICAgfQogICAgLnN0NyB7CiAgICAgIGZpbGw6ICM5NDFCMUU7CiAgICB9CiAgICAuc3Q4IHsKICAgICAgZmlsbDogI0IxMjczOTsKICAgIH0KICAgIC5zdDkgewogICAgICBmaWxsOiAjOTUyNDMyOwogICAgfQogICAgLnN0MTAgewogICAgICBmaWxsOiAjRDQyMDI3OwogICAgfQogICAgLnN0MTEgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF83XyIpOwogICAgfQogICAgLnN0MTIgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF84XyIpOwogICAgfQogICAgLnN0MTMgewogICAgICBmaWxsOiAjMUM5QTQ4OwogICAgfQogICAgLnN0MTQgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF85XyIpOwogICAgfQogICAgLnN0MTUgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF8xMF8iKTsKICAgIH0KICAgIC5zdDE2IHsKICAgICAgZmlsbDogIzJBMzg4NjsKICAgIH0KICAgIC5zdDE3IHsKICAgICAgZmlsbDogdXJsKCIjU1ZHSURfMTFfIik7CiAgICB9CiAgICAuc3QxOCB7CiAgICAgIGZpbGw6IHVybCgiI1NWR0lEXzEyXyIpOwogICAgfQogICAgLnN0MTkgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF8xM18iKTsKICAgIH0KICAgIC5zdDIwIHsKICAgICAgZmlsbDogdXJsKCIjU1ZHSURfMTRfIik7CiAgICB9CiAgPC9zdHlsZT4KICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMS41NSwtMy4zKSI+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzFfIiB5Mj0iLTMyLjY2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHkxPSItMzIuNjYzIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9Ijg0Mi4wOCIgeDE9IjExOC45OCI+PHN0b3Agc3RvcC1jb2xvcj0iIzk1MjMzMSIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzkyMUMxRCIgb2Zmc2V0PSIuMDM2MDk0Ii8+PHN0b3Agc3RvcC1jb2xvcj0iI0IwMjczOSIgb2Zmc2V0PSIuMDg0NjQ5Ii8+PHN0b3Agc3RvcC1jb2xvcj0iI0FEMUYyNCIgb2Zmc2V0PSIuMTIzNyIvPjxzdG9wIHN0b3AtY29sb3I9IiNDNzIwMjYiIG9mZnNldD0iLjE1MDkiLz48c3RvcCBzdG9wLWNvbG9yPSIjRDQyMDI3IiBvZmZzZXQ9Ii4xNjk3Ii8+PHN0b3Agc3RvcC1jb2xvcj0iI0NDMjQzMSIgb2Zmc2V0PSIuMTc1OCIvPjxzdG9wIHN0b3AtY29sb3I9IiNCNzJCNEMiIG9mZnNldD0iLjE4ODgiLz48c3RvcCBzdG9wLWNvbG9yPSIjOTUzMzcxIiBvZmZzZXQ9Ii4yMDc0Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzg4MzU3RiIgb2Zmc2V0PSIuMjE0MiIvPjxzdG9wIHN0b3AtY29sb3I9IiM4NTM2ODEiIG9mZnNldD0iLjI0MzYiLz48c3RvcCBzdG9wLWNvbG9yPSIjNkYzNjhCIiBvZmZzZXQ9Ii4yNjM4Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzM5NDI4RiIgb2Zmc2V0PSIuMjkxMSIvPjxzdG9wIHN0b3AtY29sb3I9IiMyMzNEN0QiIG9mZnNldD0iLjMyNDIiLz48c3RvcCBzdG9wLWNvbG9yPSIjMzIyQzZGIiBvZmZzZXQ9Ii40MTgxIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzJBMzg4NSIgb2Zmc2V0PSIuNDk0Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzFENjJBMSIgb2Zmc2V0PSIuNTU4MSIvPjxzdG9wIHN0b3AtY29sb3I9IiMyNzZDQTUiIG9mZnNldD0iLjU3MDIiLz48c3RvcCBzdG9wLWNvbG9yPSIjNDM4RUIzIiBvZmZzZXQ9Ii42MTAzIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzU1QTVCQyIgb2Zmc2V0PSIuNjM5OSIvPjxzdG9wIHN0b3AtY29sb3I9IiM1Q0FGQkYiIG9mZnNldD0iLjY1NTYiLz48c3RvcCBzdG9wLWNvbG9yPSIjNTZBQkJEIiBvZmZzZXQ9Ii42Nzc3Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzQzOUZCOCIgb2Zmc2V0PSIuNzA1OCIvPjxzdG9wIHN0b3AtY29sb3I9IiMxODhFQUYiIG9mZnNldD0iLjczNzIiLz48c3RvcCBzdG9wLWNvbG9yPSIjMDM4QkFFIiBvZmZzZXQ9Ii43NDI2Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzA2OTI5MiIgb2Zmc2V0PSIuNzg5OCIvPjxzdG9wIHN0b3AtY29sb3I9IiMwNUExNEIiIG9mZnNldD0iLjg4NzUiLz48c3RvcCBzdG9wLWNvbG9yPSIjMDM5MjdFIiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50PjxyZWN0IHdpZHRoPSI3MjMuMSIgeT0iMCIgeD0iMCIgaGVpZ2h0PSIzMDYuNCIgY2xhc3M9InN0MCIgZmlsbD0idXJsKCNTVkdJRF8xXykiLz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMl8iIHkyPSItMTA5LjI2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9Ii0xMDkuMjYiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgLTExOC45OCAxMjAuNTQpIiB4Mj0iMjM1Ljk4IiB4MT0iMzI1LjA4Ij48c3RvcCBzdG9wLWNvbG9yPSIjODkzNjgwIiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjODkzNjgwIiBvZmZzZXQ9Ii4zMzU0Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzhEMzE2RCIgb2Zmc2V0PSIuNTAyNSIvPjxzdG9wIHN0b3AtY29sb3I9IiM5MDI5NEQiIG9mZnNldD0iLjgzOTgiLz48c3RvcCBzdG9wLWNvbG9yPSIjOTAyNTQxIiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50Pjxwb2x5Z29uIHBvaW50cz0iMTc1LjEgMTUzLjIgMTE3IDMwNi40IDIwNi4xIDMwNi40IiBmaWxsPSJ1cmwoI1NWR0lEXzJfKSIgY2xhc3M9InN0MSIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF8zXyIgeTI9Ii04Mi4yODQiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTIwLjI0IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjQ0Ni41NSIgeDE9IjQ3OC45MyI+PHN0b3Agc3RvcC1jb2xvcj0iIzMyMkM2RiIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzMyMkM2RiIgb2Zmc2V0PSIuMjQyNyIvPjxzdG9wIHN0b3AtY29sb3I9IiMzMDJGNzIiIG9mZnNldD0iLjQ1OTkiLz48c3RvcCBzdG9wLWNvbG9yPSIjMkEzQTdFIiBvZmZzZXQ9Ii43MTU1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzE1NEE5MyIgb2Zmc2V0PSIuOTg5NiIvPjxzdG9wIHN0b3AtY29sb3I9IiMxMzRCOTQiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PHBvbHlnb24gcG9pbnRzPSIyODguNCAxNTMuMiAzMTAuNyAzMDYuNCAzNTguMSAzMDYuNCAzNTguMSAwIDMxMi45IDAiIGZpbGw9InVybCgjU1ZHSURfM18pIiBjbGFzcz0ic3QyIi8+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzRfIiB5Mj0iLTMyLjY2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHkxPSItMzIuNjYzIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjM3Mi44OCIgeDE9IjI5NC4wOCI+PHN0b3Agc3RvcC1jb2xvcj0iIzZGMzc4RCIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzNBNDI5MSIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cG9seWdvbiBwb2ludHM9IjE3NS4xIDE1My4yIDIwNi4xIDMwNi40IDI1My45IDE1My4yIDIwOS40IDAgMjA5LjQgMCIgZmlsbD0idXJsKCNTVkdJRF80XykiIGNsYXNzPSJzdDMiLz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfNV8iIHkyPSItMzIuNjYzIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9Ii0zMi42NjMiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgLTExOC45OCAxMjAuNTQpIiB4Mj0iMzI1LjA4IiB4MT0iNDMxLjg4Ij48c3RvcCBzdG9wLWNvbG9yPSIjMjMzRDdEIiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjMjkzRDdEIiBvZmZzZXQ9Ii4yNDk1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzNBM0M4MCIgb2Zmc2V0PSIuNTQ0NiIvPjxzdG9wIHN0b3AtY29sb3I9IiM1MTNCODQiIG9mZnNldD0iLjg2MTYiLz48c3RvcCBzdG9wLWNvbG9yPSIjNUQzQTg2IiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50Pjxwb2x5Z29uIHBvaW50cz0iMjUzLjkgMTUzLjIgMjA2LjEgMzA2LjQgMzEwLjcgMzA2LjQgMjg4LjQgMTUzLjIgMzEyLjkgMCAyMDkuNCAwIiBmaWxsPSJ1cmwoI1NWR0lEXzVfKSIgY2xhc3M9InN0NCIvPjxwb2x5Z29uIHBvaW50cz0iMTE2LjEgMCA1NS43IDAgNTUuNyA5NC44IDg5LjkgMTUzLjIgNTUuNyAyMTEuNiA1NS43IDMwNi40IDExNyAzMDYuNCA5NS4yIDE1My4yIiBmaWxsPSIjYWYyMDI0IiBjbGFzcz0ic3Q1Ii8+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzZfIiB5Mj0iNDMuOTM3IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9IjQzLjkzNyIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAtMTE4Ljk4IDEyMC41NCkiIHgyPSIyMzIuNjciIHgxPSIzMjkuMTEiPjxzdG9wIHN0b3AtY29sb3I9IiM4OTM2ODAiIG9mZnNldD0iMCIvPjxzdG9wIHN0b3AtY29sb3I9IiM4OTM2ODAiIG9mZnNldD0iLjMzNTQiLz48c3RvcCBzdG9wLWNvbG9yPSIjOEQzMTZEIiBvZmZzZXQ9Ii41MDI1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzkwMjk0RCIgb2Zmc2V0PSIuODM5OCIvPjxzdG9wIHN0b3AtY29sb3I9IiM5MDI1NDEiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PHBvbHlnb24gcG9pbnRzPSIxNzUuMSAxNTMuMiAyMDkuNCAwIDExNi4xIDAiIGZpbGw9InVybCgjU1ZHSURfNl8pIiBjbGFzcz0ic3Q2Ii8+PHBvbHlnb24gcG9pbnRzPSI1NS43IDk0LjggNTUuNyAwIDAgMCIgZmlsbD0iIzk0MWIxZSIgY2xhc3M9InN0NyIvPjxwb2x5Z29uIHBvaW50cz0iNTUuNyAyMTEuNiA4OS45IDE1My4yIDU1LjcgOTQuOCIgZmlsbD0iI2IxMjczOSIgY2xhc3M9InN0OCIvPjxwb2x5Z29uIHBvaW50cz0iNTUuNyAyMTEuNiAwIDMwNi40IDU1LjcgMzA2LjQiIGZpbGw9IiM5NDFiMWUiIGNsYXNzPSJzdDciLz48cG9seWdvbiBwb2ludHM9IjU1LjcgOTQuOCAwIDAgMCAzMDYuNCA1NS43IDIxMS42IiBmaWxsPSIjOTUyNDMyIiBjbGFzcz0ic3Q5Ii8+PHBvbHlnb24gcG9pbnRzPSIxMTYuMSAwIDk1LjIgMTUzLjIgMTE3IDMwNi40IDE3NS4xIDE1My4yIiBmaWxsPSIjZDQyMDI3IiBjbGFzcz0ic3QxMCIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF83XyIgeTI9Ii0xODYuMDYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTIwLjQ0IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9Ijc0OC45NiIgeDE9Ijc0OC45NiI+PHN0b3Agc3RvcC1jb2xvcj0iIzk0QkU1NSIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzkzQkQ1OCIgb2Zmc2V0PSIuMDQ0MzQwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzhCQkM2QSIgb2Zmc2V0PSIuMzg5MSIvPjxzdG9wIHN0b3AtY29sb3I9IiM4NkJDNzUiIG9mZnNldD0iLjcxNDkiLz48c3RvcCBzdG9wLWNvbG9yPSIjODRCQzc5IiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50PjxwYXRoCiAgICAgIGQ9Im02NDEuNiAyNTkuNmMxLjctMjUuNCAxMC01NC42IDE4LjgtODUuNiAxLjQtNSAyLjgtMTAgNC4yLTE1LjEtMS40LTUuNS0yLjgtMTAuOS00LjItMTYuMi04LjgtMzMuMy0xNy02NC43LTE4LjgtOTItMS40LTIxLjIgMS40LTM3IDguOS01MC42aC00NS45Yy03LjUgMTguMy0xMC4zIDI5LjEtOC45IDUwLjMgMS43IDI3LjMgMTAgNTguNyAxOC44IDkyIDEzIDQ5LjMgMjggMTA2LjIgMjMuMiAxNjQuMmgxMi45Yy03LjYtMTIuOC0xMC40LTI3LjMtOS00N3oiCiAgICAgIGNsYXNzPSJzdDExIgogICAgICBmaWxsPSJ1cmwoI1NWR0lEXzdfKSIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF84XyIgeTI9Ii0xODQuNDUiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTE3LjI5IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjczMy40OSIgeDE9IjY1My43NiI+PHN0b3Agc3RvcC1jb2xvcj0iIzA4QTI0QiIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzBBQTE0RSIgb2Zmc2V0PSIuMTY3OCIvPjxzdG9wIHN0b3AtY29sb3I9IiMwQjlFNTciIG9mZnNldD0iLjQwNDciLz48c3RvcCBzdG9wLWNvbG9yPSIjMDk5QTY3IiBvZmZzZXQ9Ii42ODI3Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzA0OTQ3RCIgb2Zmc2V0PSIuOTg5OCIvPjxzdG9wIHN0b3AtY29sb3I9IiMwNDkzN0UiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PHBhdGggZD0ibTYxNC41IDE0Mi4zYy04LjgtMzMuMy0xNy02NC43LTE4LjgtOTItMS40LTIxLjIgMS40LTMyIDguOS01MC4zaC0zNS40YzUuNyA1My45LTMuOCAxMDYuNy0xMy42IDE2Ni44LTUuNyAzNS0xMS43IDcxLjMtMTMuMiAxMDAuNi0xLjEgMjEuMSAwLjQgMzIuOCAxLjggMzloOTMuNWM0LjgtNTcuOS0xMC4zLTExNC44LTIzLjItMTY0LjF6IiBjbGFzcz0ic3QxMiIgZmlsbD0idXJsKCNTVkdJRF84XykiLz48cGF0aCBjbGFzcz0ic3QxMyIgZmlsbD0iIzFjOWE0OCIgZD0ibTY2NC42IDE1OC45Yy0xLjQgNS4xLTIuOCAxMC4xLTQuMiAxNS4xLTguOCAzMS0xNyA2MC4yLTE4LjggODUuNi0xLjQgMTkuNyAxLjQgMzQuMiA5IDQ2LjloMzNjNC4yLTUxLjgtNy4yLTEwMi4zLTE5LTE0Ny42eiIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF85XyIgeTI9Ii0xODUuOTYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTIwLjU0IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjgxMi44MyIgeDE9IjgxMi44MyI+PHN0b3Agc3RvcC1jb2xvcj0iIzY5QTA2MCIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzYzOUQ1QyIgb2Zmc2V0PSIuMDM5ODk1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzRDOTQ0RiIgb2Zmc2V0PSIuMjE5MiIvPjxzdG9wIHN0b3AtY29sb3I9IiMzNzhFNDciIG9mZnNldD0iLjQxODQiLz48c3RvcCBzdG9wLWNvbG9yPSIjMjk4QjQ0IiBvZmZzZXQ9Ii42NTE1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzIzOEE0MyIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cGF0aCBkPSJtNjgwLjUgMGMxMC43IDU1LjMtMi41IDExMC40LTE1LjkgMTU4LjkgMTEuNyA0NS4zIDIzLjIgOTUuOCAxOC45IDE0Ny42aDM5LjZ2LTMwNi41aC00Mi42eiIgY2xhc3M9InN0MTQiIGZpbGw9InVybCgjU1ZHSURfOV8pIi8+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzEwXyIgeTI9Ii0xODUuODYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTIwLjU0IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjY1Mi40NSIgeDE9IjY1Mi40NSI+PHN0b3Agc3RvcC1jb2xvcj0iIzA1QjVEQyIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzA0QjBENyIgb2Zmc2V0PSIuMjE5NyIvPjxzdG9wIHN0b3AtY29sb3I9IiMwNUE0QzkiIG9mZnNldD0iLjUzNzEiLz48c3RvcCBzdG9wLWNvbG9yPSIjMDU5MUI0IiBvZmZzZXQ9Ii45MTIyIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzA1OENBRSIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cGF0aCBkPSJtNTQyLjMgMjY3LjRjMS41LTI5LjQgNy41LTY1LjYgMTMuMi0xMDAuNiA5LjgtNjAuMSAxOS4zLTExMi44IDEzLjYtMTY2LjhoLTcwLjhjLTEuNCAxMS40LTIuOSAxOS4yLTEuOCA0MS44IDEuNSAzMS42IDcuNSA3MC41IDEzLjIgMTA4LjIgOC40IDU1LjQgMTYuNiAxMDguOCAxNS4xIDE1Ni40aDE5LjJjLTEuMy02LjItMi44LTE3LjktMS43LTM5eiIgY2xhc3M9InN0MTUiIGZpbGw9InVybCgjU1ZHSURfMTBfKSIvPjxwb2x5Z29uIHBvaW50cz0iMzc1LjcgMTUzLjIgMzU4LjEgMCAzNTguMSAzMDYuNCIgZmlsbD0iIzJhMzg4NiIgY2xhc3M9InN0MTYiLz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMTFfIiB5Mj0iNzcuMTM2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9Ii00LjMyODEiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgLTExOC45OCAxMjAuNTQpIiB4Mj0iNzk2LjcxIiB4MT0iNzUxLjA1Ij48c3RvcCBzdG9wLWNvbG9yPSIjNjJCMTZFIiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjODdCOTU3IiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50PjxwYXRoIGQ9Im02NDEuNiA1MC42YzEuNyAyNy4zIDEwIDU4LjcgMTguOCA5MiAxLjQgNS4zIDIuOCAxMC43IDQuMiAxNi4yIDEzLjUtNDguNCAyNi42LTEwMy41IDE1LjktMTU4LjhoLTMwYy03LjUgMTMuNi0xMC4zIDI5LjQtOC45IDUwLjZ6IiBjbGFzcz0ic3QxNyIgZmlsbD0idXJsKCNTVkdJRF8xMV8pIi8+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzEyXyIgeTI9Ii0xODkuMjgiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTEzLjcxIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjYzMS41OSIgeDE9IjU1MC40Ij48c3RvcCBzdG9wLWNvbG9yPSIjMDY5QUQ0IiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjMzBBMENFIiBvZmZzZXQ9Ii4zNTI1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzVCQjBDMCIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cGF0aCBkPSJtNTA5LjggMTUwYy01LjctMzcuNy0xMS43LTc2LjYtMTMuMi0xMDguMi0xLjEtMjIuNyAwLjQtMzAuNCAxLjgtNDEuOGgtNDEuNWMxLjUgNDAuMS0xLjUgODUuMy03IDE2MC44LTMuMSA0My41LTggMTEwLjUtNyAxNDUuN2g4Mi4xYzEuNC00Ny43LTYuOC0xMDEuMS0xNS4yLTE1Ni41eiIgY2xhc3M9InN0MTgiIGZpbGw9InVybCgjU1ZHSURfMTJfKSIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF8xM18iIHkyPSItMTg1Ljg2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9IjEyMC41NCIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAtMTE4Ljk4IDEyMC41NCkiIHgyPSI1MDUuMzMiIHgxPSI1MDUuMzMiPjxzdG9wIHN0b3AtY29sb3I9IiMxRTQ1OEUiIG9mZnNldD0iMCIvPjxzdG9wIHN0b3AtY29sb3I9IiMxRjRGOTYiIG9mZnNldD0iLjI0MTEiLz48c3RvcCBzdG9wLWNvbG9yPSIjMkI2QUFCIiBvZmZzZXQ9Ii43MjkyIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzMzN0JCOSIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cG9seWdvbiBwb2ludHM9IjM1OC4xIDMwNi40IDQxNC42IDMwNi40IDQxNC42IDAgMzU4LjEgMCAzNzUuNyAxNTMuMiIgZmlsbD0idXJsKCNTVkdJRF8xM18pIiBjbGFzcz0ic3QxOSIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF8xNF8iIHkyPSIxMjAuNTQiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iLTE4NS44NiIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAtMTE4Ljk4IDEyMC41NCkiIHgyPSI1NTQuOTIiIHgxPSI1NTQuOTIiPjxzdG9wIHN0b3AtY29sb3I9IiMzRjlBQzkiIG9mZnNldD0iMCIvPjxzdG9wIHN0b3AtY29sb3I9IiMyMDYyQTIiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PHBhdGggZD0ibTQ0OS45IDE2MC44YzUuNS03NS41IDguNS0xMjAuNiA3LTE2MC44aC00Mi4ybC0wLjEgMzA2LjRoMjguM2MtMS0zNS4xIDMuOC0xMDIuMSA3LTE0NS42eiIgY2xhc3M9InN0MjAiIGZpbGw9InVybCgjU1ZHSURfMTRfKSIvPjwvZz4KPC9zdmc+Cg==); +} + +body:after { + position: absolute; + right: 25px; + top: 36px; + width: 135px; + height: 48px; + content: ''; + background-repeat: no-repeat; + background-size: cover; + background-image: url(/static/img/bosch-logo.svg); +} + +.swagger-ui { + font-family: "Bosch Sans", sans-serif; +} + +/*Remove topbar*/ +.swagger-ui .topbar { + display: none +} + +/*Remove models view*/ +.swagger-ui .models { + display: none; +} + +/*Remove application/json select*/ +.swagger-ui .opblock .opblock-section-header > label, .swagger-ui .response-controls { + display: none; +} + +/*Remove border radius*/ +.swagger-ui .opblock, .swagger-ui .opblock .opblock-summary-method, .swagger-ui select { + border-radius: 0; + box-shadow: none; +} + +/*remove links in response*/ +.swagger-ui .response-col_links { + display: none; +} + +/*remove version*/ +.swagger-ui .info .title span { + display: none; +} + +/*separator before methods*/ +.swagger-ui .scheme-container { + box-shadow: none; + border-bottom: 1px solid var(--light-grey); +} + +/*tag separator*/ +.swagger-ui .opblock-tag { + border-bottom: 1px solid var(--light-grey); +} + +/*parameters/responses bar*/ +.swagger-ui .opblock .opblock-section-header { + box-shadow: none; + background: #fff; +} + +/*select*/ +.swagger-ui select { + background-color: var(--light-grey-w75); + border: none; + height: 36px; +} + +/*button*/ +.swagger-ui .btn { + border-radius: 0; + box-shadow: none; +} + +.swagger-ui .btn:hover { + box-shadow: none; +} + +/*authorize button */ +.swagger-ui .btn.authorize { + color: var(--light-green); + border-color: var(--light-green); +} + +.swagger-ui .btn.authorize svg { + fill: var(--light-green); +} + +/*auth inputs*/ +.swagger-ui .auth-container input[type="password"], .swagger-ui .auth-container input[type="text"] { + border-radius: 0; + box-shadow: none; + border-color: var(--light-grey); +} + +.swagger-ui .dialog-ux .modal-ux { + border-radius: 0; +} + +/*cancel button*/ +.swagger-ui .btn.cancel { + color: var(--red); + border-color: var(--red); +} + +/*model*/ +.swagger-ui .model-box { + border-radius: 0; +} + +/*execute button*/ +.swagger-ui .btn.execute { + background-color: var(--dark-blue); + border-color: var(--dark-blue); +} + +/*parameter input*/ +.swagger-ui .parameters-col_description input[type="text"] { + border-radius: 0; +} + +/*Remove colored parameters bar*/ +.swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after { + background: none; +} + +/*code*/ +.swagger-ui .opblock-body pre.microlight { + border-radius: 0; +} + +.swagger-ui .highlight-code > .microlight { + min-height: 0; +} + +/*request body*/ +.swagger-ui textarea { + border-radius: 0; +} + +/*parameters smaller padding*/ +.swagger-ui .execute-wrapper { + padding-top: 0; + padding-bottom: 0; +} + +.swagger-ui .btn.execute { + margin-bottom: 20px; +} + +.swagger-ui .opblock-description-wrapper { + margin-top: 20px; +} + +.swagger-ui .opblock-description-wrapper { + margin-top: 5px; +} + +/*response element positions*/ +.swagger-ui .model-example { + position: relative; + margin-top: 0; +} + +.swagger-ui .tab { + position: absolute; + top: -35px; + right: 0; +} + +.swagger-ui table tbody tr td { + padding: 0; +} + +.swagger-ui .renderedMarkdown p { + margin: 8px auto; +} + +/*Method colors*/ +.swagger-ui .opblock.opblock-get .opblock-summary-method { + background: var(--dark-blue); +} + +.swagger-ui .opblock.opblock-get .opblock-summary { + border-color: var(--dark-blue); +} + +.swagger-ui .opblock.opblock-get { + background: var(--dark-blue-w75); + border-color: var(--dark-blue); +} + +.swagger-ui .opblock.opblock-post .opblock-summary-method { + background: var(--dark-green); +} + +.swagger-ui .opblock.opblock-post .opblock-summary { + border-color: var(--dark-green); +} + +.swagger-ui .opblock.opblock-post { + background: var(--dark-green-w75); + border-color: var(--dark-green); +} + +.swagger-ui .opblock.opblock-put .opblock-summary-method { + background: var(--turquoise); +} + +.swagger-ui .opblock.opblock-put .opblock-summary { + border-color: var(--turquoise); +} + +.swagger-ui .opblock.opblock-put { + background: var(--turquoise-w75); + border-color: var(--turquoise); +} + +.swagger-ui .opblock.opblock-delete .opblock-summary-method { + background: var(--fuchsia); +} + +.swagger-ui .opblock.opblock-delete .opblock-summary { + border-color: var(--fuchsia); +} + +.swagger-ui .opblock.opblock-delete { + background: var(--fuchsia-w75); + border-color: var(--fuchsia); +} \ No newline at end of file From 5f20afcf0415e4312e59ff030bc19b866399534e Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Wed, 29 Apr 2020 16:09:31 +0200 Subject: [PATCH 14/83] finished /material methods --- api/material.yaml | 4 +-- src/routes/material.spec.ts | 26 +++++++++++------ src/routes/material.ts | 56 +++++++++++++++++++++++++++++++++++-- src/routes/user.ts | 2 +- static/styles/swagger.css | 4 +++ 5 files changed, 78 insertions(+), 14 deletions(-) diff --git a/api/material.yaml b/api/material.yaml index c32c1ab..8e3a039 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -40,7 +40,7 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: TODO change material + summary: change material description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /material @@ -79,8 +79,6 @@ responses: 200: $ref: 'api.yaml#/components/responses/Ok' - 400: - $ref: 'api.yaml#/components/responses/400' 401: $ref: 'api.yaml#/components/responses/401' 403: diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index bb82bc1..e98da67 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -130,6 +130,16 @@ describe('/material', () => { res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}]} }); }); + it('returns keeps unchanged properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}]}, + res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}]} + }); + }); it('changes the given properties', done => { TestHelper.request(server, done, { method: 'put', @@ -140,9 +150,11 @@ describe('/material', () => { , }).end(err => { if (err) return done(err); - MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { + MaterialModel.findById('100000000000000000000001').lean().exec((err, data:any) => { if (err) return done(err); - should(data).be.eql({_id: '100000000000000000000002', name: 'UltramidTKR4355G7', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}], __v: 0} + data._id = data._id.toString(); + data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); + should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: '0', glass_fiber: '35', carbon_fiber: '0', numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}], __v: 0} ); done(); }); @@ -154,7 +166,7 @@ describe('/material', () => { url: '/material/100000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {name: 'UltramidTKR4355G7'}, + req: {name: 'Ultramid T KR 4355 G7'}, res: {status: 'Material name already taken'} }); }); @@ -173,9 +185,8 @@ describe('/material', () => { method: 'put', url: '/material/10000000000000000000000x', auth: {basic: 'admin'}, - httpStatus: 400, + httpStatus: 404, req: {}, - res: {status: 'Invalid id'} }); }); it('rejects an API key', done => { @@ -227,7 +238,7 @@ describe('/material', () => { should(res.body).be.eql({status: 'OK'}); MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { if (err) return done(err); - should(data).have.lengthOf(0); + should(data).be.null(); done(); }); }); @@ -238,8 +249,7 @@ describe('/material', () => { method: 'delete', url: '/material/10000000000000000000000x', auth: {basic: 'admin'}, - httpStatus: 400, - res: {status: 'Invalid id'} + httpStatus: 404 }); }); it('rejects an API key', done => { diff --git a/src/routes/material.ts b/src/routes/material.ts index 42911e3..f193b9f 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -31,6 +31,58 @@ router.get('/material/' + IdValidate.parameter(), (req, res, next) => { }); }); +router.put('/material/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const {error, value: material} = MaterialValidate.input(req.body, 'change'); + if(error !== undefined) { + res.status(400).json({status: 'Invalid body format'}); + return; + } + + if (material.hasOwnProperty('name')) { + MaterialModel.find({name: material.name}).lean().exec((err, data) => { + if(err) next(err); + if (data.length > 0 && data[0]._id != req.params.id) { + res.status(400).json({status: 'Material name already taken'}); + return; + } + else { + f(); + } + }); + } + else { + f(); + } + + function f() { // to resolve async + MaterialModel.findByIdAndUpdate(req.params.id, material).lean().exec((err, data) => { + if (err) next(err); + if (data) { + res.json(MaterialValidate.output(data)); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); + } +}); + +router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + MaterialModel.findByIdAndDelete(req.params.id).lean().exec((err, data) => { + if (err) next(err); + if (data) { + res.json({status: 'OK'}) + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + router.post('/material/new', (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; @@ -52,8 +104,8 @@ router.post('/material/new', (req, res, next) => { if(err) next(err); res.json(MaterialValidate.output(data.toObject())); }); - }) -}) + }); +}); module.exports = router; \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts index aabb8a2..c60dd7b 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -67,7 +67,7 @@ router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi UserModel.findOneAndUpdate({name: username}, user, {new: true}).lean().exec( (err, data:any) => { if (err) next(err); if (data) { - res.json(UserValidate.output(data)); // validate all and filter null values from validation errors + res.json(UserValidate.output(data)); } else { res.status(404).json({status: 'Not found'}); diff --git a/static/styles/swagger.css b/static/styles/swagger.css index b86a643..ac69d38 100644 --- a/static/styles/swagger.css +++ b/static/styles/swagger.css @@ -197,6 +197,10 @@ body:after { margin-top: 5px; } +.opblock-section .opblock-section-request-body > div > div { + padding-top: 18px; +} + /*response element positions*/ .swagger-ui .model-example { position: relative; From af071a9445e16534f845eb60d6171abd6b1e6815 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 4 May 2020 15:48:07 +0200 Subject: [PATCH 15/83] finished /template methods --- api/api.yaml | 2 +- api/material.yaml | 4 +- api/parameters.yaml | 1 + api/template.yaml | 61 +-- package.json | 2 +- src/db.ts | 2 + src/index.ts | 1 + src/models/measurement_template.ts | 11 + src/models/treatment_template.ts | 11 + src/routes/material.spec.ts | 7 +- src/routes/material.ts | 2 +- src/routes/template.spec.ts | 579 +++++++++++++++++++++++++++++ src/routes/template.ts | 91 +++++ src/routes/validate/template.ts | 59 +++ src/test/db.json | 66 ++++ static/styles/swagger.css | 31 ++ 16 files changed, 898 insertions(+), 32 deletions(-) create mode 100644 src/models/measurement_template.ts create mode 100644 src/models/treatment_template.ts create mode 100644 src/routes/template.spec.ts create mode 100644 src/routes/template.ts create mode 100644 src/routes/validate/template.ts diff --git a/api/api.yaml b/api/api.yaml index 6e25698..44756ae 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -48,7 +48,7 @@ tags: - name: /material - name: /condition - name: /measurement - - name: /templates + - name: /template - name: /model - name: /user diff --git a/api/material.yaml b/api/material.yaml index 8e3a039..a3b80da 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -70,7 +70,7 @@ 500: $ref: 'api.yaml#/components/responses/500' delete: - summary: TODO delete material + summary: delete material description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /material @@ -90,7 +90,7 @@ /material/new: post: - summary: TODO add material + summary: add material description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /material diff --git a/api/parameters.yaml b/api/parameters.yaml index f370c13..ba8d046 100644 --- a/api/parameters.yaml +++ b/api/parameters.yaml @@ -7,6 +7,7 @@ Id: example: 5ea0450ed851c30a90e70894 Name: name: name + description: has to be URL encoded in: path required: true schema: diff --git a/api/template.yaml b/api/template.yaml index 9219d81..5b362fb 100644 --- a/api/template.yaml +++ b/api/template.yaml @@ -1,9 +1,9 @@ /template/treatments: get: - summary: TODO all available treatment methods + summary: all available treatment methods description: 'Auth: basic, levels: read, write, maintain, dev, admin' tags: - - /templates + - /template security: - BasicAuth: [] responses: @@ -16,23 +16,26 @@ items: $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: heat aging parameters: - name: method range: - - copper + values: + - copper + - hot air 401: $ref: 'api.yaml#/components/responses/401' 500: $ref: 'api.yaml#/components/responses/500' -/templates/treatment/{name}: +/template/treatment/{name}: parameters: - $ref: 'api.yaml#/components/parameters/Name' get: - summary: TODO treatment method details + summary: treatment method details description: 'Auth: basic, levels: read, write, maintain, admin' tags: - - /templates + - /template security: - BasicAuth: [] responses: @@ -44,13 +47,14 @@ allOf: - $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: heat aging parameters: - name: method range: - - copper - 400: - $ref: 'api.yaml#/components/responses/400' + values: + - copper + - hot air 401: $ref: 'api.yaml#/components/responses/401' 404: @@ -58,10 +62,10 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: TODO add/change treatment method + summary: add/change treatment method description: 'Auth: basic, levels: maintain, admin' tags: - - /templates + - /template security: - BasicAuth: [] requestBody: @@ -76,7 +80,9 @@ parameters: - name: method range: - - copper + values: + - copper + - hot air responses: 200: description: treatment details @@ -86,11 +92,14 @@ allOf: - $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: heat aging parameters: - name: method range: - - copper + values: + - copper + - hot air 400: $ref: 'api.yaml#/components/responses/400' 401: @@ -102,10 +111,10 @@ 500: $ref: 'api.yaml#/components/responses/500' delete: - summary: TODO delete treatment method + summary: delete treatment method description: 'Auth: basic, levels: maintain, admin' tags: - - /templates + - /template security: - BasicAuth: [] responses: @@ -123,10 +132,10 @@ $ref: 'api.yaml#/components/responses/500' /template/measurements: get: - summary: TODO all available measurement methods + summary: all available measurement methods description: 'Auth: basic, levels: read, write, maintain, dev, admin' tags: - - /templates + - /template security: - BasicAuth: [] responses: @@ -139,6 +148,7 @@ items: $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: humidity parameters: - name: kf @@ -149,14 +159,14 @@ $ref: 'api.yaml#/components/responses/401' 500: $ref: 'api.yaml#/components/responses/500' -/templates/measurement/{name}: +/template/measurement/{name}: parameters: - $ref: 'api.yaml#/components/parameters/Name' get: - summary: TODO measurement method details + summary: measurement method details description: 'Auth: basic, levels: read, write, maintain, admin' tags: - - /templates + - /template security: - BasicAuth: [] responses: @@ -168,6 +178,7 @@ allOf: - $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: humidity parameters: - name: kf @@ -183,10 +194,10 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: TODO add/change measurement method + summary: add/change measurement method description: 'Auth: basic, levels: maintain, admin' tags: - - /templates + - /template security: - BasicAuth: [] requestBody: @@ -197,6 +208,7 @@ allOf: - $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: humidity parameters: - name: kf @@ -212,6 +224,7 @@ allOf: - $ref: 'api.yaml#/components/schemas/Template' example: + _id: 5ea0450ed851c30a90e70894 name: humidity parameters: - name: kf @@ -229,10 +242,10 @@ 500: $ref: 'api.yaml#/components/responses/500' delete: - summary: TODO delete measurement method + summary: delete measurement method description: 'Auth: basic, levels: maintain, admin' tags: - - /templates + - /template security: - BasicAuth: [] responses: diff --git a/package.json b/package.json index 4753647..d3f9e63 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "tsc": "tsc", "test": "mocha dist/**/**.spec.js", - "start": "tsc && node dist/index.js", + "start": "tsc && node dist/index.js || exit 1", "dev": "nodemon -e ts,yaml --exec \"npm run start\"" }, "keywords": [], diff --git a/src/db.ts b/src/db.ts index e624696..090e275 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,6 +1,8 @@ import mongoose from 'mongoose'; import cfenv from 'cfenv'; +// mongoose.set('debug', true); // enable mongoose debug + // database urls, prod db url is retrieved automatically const TESTING_URL = 'mongodb://localhost/dfopdb_test'; const DEV_URL = 'mongodb://localhost/dfopdb'; diff --git a/src/index.ts b/src/index.ts index f79a554..15bd504 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ app.use(require('./helpers/authorize')); // handle authentication app.use('/', require('./routes/root')); app.use('/', require('./routes/user')); app.use('/', require('./routes/material')); +app.use('/', require('./routes/template')); // static files app.use('/static', express.static('static')); diff --git a/src/models/measurement_template.ts b/src/models/measurement_template.ts new file mode 100644 index 0000000..c55cbc7 --- /dev/null +++ b/src/models/measurement_template.ts @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; + +const MeasurementTemplateSchema = new mongoose.Schema({ + name: {type: String, index: {unique: true}}, + parameters: [{ + name: String, + range: mongoose.Schema.Types.Mixed + }] +}, {minimize: false}); // to allow empty objects + +export default mongoose.model('measurement_template', MeasurementTemplateSchema); \ No newline at end of file diff --git a/src/models/treatment_template.ts b/src/models/treatment_template.ts new file mode 100644 index 0000000..3b61164 --- /dev/null +++ b/src/models/treatment_template.ts @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; + +const TreatmentTemplateSchema = new mongoose.Schema({ + name: {type: String, index: {unique: true}}, + parameters: [{ + name: String, + range: mongoose.Schema.Types.Mixed + }] +}, {minimize: false}); // to allow empty objects + +export default mongoose.model('treatment_template', TreatmentTemplateSchema); \ No newline at end of file diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index e98da67..c69538a 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -130,7 +130,7 @@ describe('/material', () => { res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}]} }); }); - it('returns keeps unchanged properties', done => { + it('keeps unchanged properties', done => { TestHelper.request(server, done, { method: 'put', url: '/material/100000000000000000000001', @@ -148,11 +148,12 @@ describe('/material', () => { httpStatus: 200, req: {name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}]} , - }).end(err => { + }).end((err, res) => { if (err) return done(err); + should(res.body).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}]}); MaterialModel.findById('100000000000000000000001').lean().exec((err, data:any) => { if (err) return done(err); - data._id = data._id.toString(); + data._id = data._id.toString({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}]}); data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: '0', glass_fiber: '35', carbon_fiber: '0', numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}], __v: 0} ); diff --git a/src/routes/material.ts b/src/routes/material.ts index f193b9f..c44afa7 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -57,7 +57,7 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { } function f() { // to resolve async - MaterialModel.findByIdAndUpdate(req.params.id, material).lean().exec((err, data) => { + MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).lean().exec((err, data) => { if (err) next(err); if (data) { res.json(MaterialValidate.output(data)); diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts new file mode 100644 index 0000000..5ee4d1a --- /dev/null +++ b/src/routes/template.spec.ts @@ -0,0 +1,579 @@ +import should from 'should/as-function'; +import TemplateTreatmentModel from '../models/treatment_template'; +import TemplateMeasurementModel from '../models/measurement_template'; +import TestHelper from "../helpers/test"; + + +describe('/template', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + + describe('/template/treatment', () => { + describe('GET /template/treatments', () => { + it('returns all treatment templates', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatments', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.treatment_templates.length); + should(res.body).matchEach(treatment => { + should(treatment).have.only.keys('_id', 'name', 'parameters'); + should(treatment).have.property('_id').be.type('string'); + should(treatment).have.property('name').be.type('string'); + should(treatment.parameters).matchEach(number => { + should(number).have.only.keys('name', 'range'); + should(number).have.property('name').be.type('string'); + should(number).have.property('range').be.type('object'); + }); + }); + done(); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatments', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatments', + httpStatus: 401 + }); + }); + }); + + describe('GET /template/treatment/{name}', () => { + it('returns the right treatment template', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatment/heat%20treatment', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects an unknown name', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatment/xxx', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/treatment/heat%20treatment', + httpStatus: 401 + }); + }); + }); + + describe('PUT /template/treatment/{name}', () => { + it('returns the right treatment template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + }); + }); + it('keeps unchanged properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]}, + res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + }); + }); + it('changes the given properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '200000000000000000000001', name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}); + TemplateTreatmentModel.find({name: 'heat aging'}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'parameters'); + should(data[0]).have.property('name', 'heat aging'); + should(data[0]).have.property('parameters').have.lengthOf(1); + should(data[0].parameters[0]).have.property('name', 'time'); + should(data[0].parameters[0]).have.property('range'); + should(data[0].parameters[0].range).have.property('min', 1); + done(); + }); + }); + }); + it('supports values ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'time', range: {values: [1, 2, 5]}}]}, + res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'time', range: {values: [1, 2, 5]}}]} + }); + }); + it('supports min max ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'time', range: {min: 1, max: 11}}]}, + res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'time', range: {min: 1, max: 11}}]} + }); + }); + it('supports empty ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'time', range: {}}]}, + res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'time', range: {}}]} + }); + }); + it('adds a new template for an unknown name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + }).end(err => { + if (err) return done(err); + TemplateTreatmentModel.find({name: 'heat aging'}).lean().exec((err, data:any) => { + if (err) return done(err); + console.log(data); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'parameters', '__v'); + should(data[0]).have.property('name', 'heat aging'); + should(data[0].parameters[0]).have.property('name', 'time'); + should(data[0].parameters[0]).have.property('range'); + should(data[0].parameters[0].range).have.property('min', 1); + done(); + }); + }); + }); + it('rejects an incomplete template for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'time'}]}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects already existing names', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat treatment 2', parameters: [{name: 'time', range: {min: 1}}]}, + res: {status: 'Template name already taken'} + }); + }); + it('rejects wrong properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'time'}], xx: 33}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20treatment', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('DELETE /template/treatment/{name}', () => { + it('deletes the template', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + TemplateTreatmentModel.find({name: 'heat treatment'}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(0); + done(); + }); + }); + }); + it('rejects deleting a template still in use'); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/treatment/heat%20treatment', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/treatment/heat%20treatment', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }) + }); + it('returns 404 for an unknown name', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/treatment/xxx', + auth: {basic: 'admin'}, + httpStatus: 404 + }) + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/treatment/heat%20treatment', + httpStatus: 401 + }) + }); + }); + }); + + describe('/template/measurement', () => { + describe('GET /template/measurements', () => { + it('returns all measurement templates', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurements', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.measurement_templates.length); + should(res.body).matchEach(measurement => { + should(measurement).have.only.keys('_id', 'name', 'parameters'); + should(measurement).have.property('_id').be.type('string'); + should(measurement).have.property('name').be.type('string'); + should(measurement.parameters).matchEach(number => { + should(number).have.only.keys('name', 'range'); + should(number).have.property('name').be.type('string'); + should(number).have.property('range').be.type('object'); + }); + }); + done(); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurements', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurements', + httpStatus: 401 + }); + }); + }); + + describe('GET /template/measurement/{name}', () => { + it('returns the right measurement template', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurement/spectrum', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {}}]} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurement/spectrum', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects an unknown name', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurement/xxx', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/template/measurement/spectrum', + httpStatus: 401 + }); + }); + }); + + describe('PUT /template/measurement/{name}', () => { + it('returns the right measurement template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {}}]} + }); + }); + it('keeps unchanged properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'spectrum', parameters: [{name: 'dpt', range: {}}]}, + res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {}}]} + }); + }); + it('changes the given properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '300000000000000000000001', name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}); + TemplateMeasurementModel.find({name: 'IR spectrum'}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'parameters'); + should(data[0]).have.property('name', 'IR spectrum'); + should(data[0]).have.property('parameters').have.lengthOf(1); + should(data[0].parameters[0]).have.property('name', 'data point table'); + should(data[0].parameters[0]).have.property('range'); + should(data[0].parameters[0].range).have.property('min', 0); + should(data[0].parameters[0].range).have.property('max', 1000); + done(); + }); + }); + }); + it('supports values ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]}, + res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]} + }); + }); + it('supports min max ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]}, + res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]} + }); + }); + it('supports empty ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/kf', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'weight %', range: {}}]}, + res: {_id: '300000000000000000000002', name: 'kf', parameters: [{name: 'weight %', range: {}}]} + }); + }); + it('adds a new template for an unknown name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/vz', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]} + }).end(err => { + if (err) return done(err); + TemplateMeasurementModel.find({name: 'vz'}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'parameters', '__v'); + should(data[0]).have.property('name', 'vz'); + should(data[0]).have.property('parameters').have.lengthOf(1); + should(data[0].parameters[0]).have.property('name', 'vz'); + should(data[0].parameters[0]).have.property('range'); + should(data[0].parameters[0].range).have.property('min', 1); + done(); + }); + }); + }); + it('rejects an incomplete template for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/vz', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'vz'}]}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects already existing names', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'kf', parameters: [{name: 'dpt', range: {min: 1}}]}, + res: {status: 'Template name already taken'} + }); + }); + it('rejects wrong properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'dpt'}], xx: 33}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('DELETE /template/measurement/{name}', () => { + it('deletes the template', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/measurement/spectrum', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + TemplateMeasurementModel.find({name: 'spectrum'}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(0); + done(); + }); + }); + }); + it('rejects deleting a template still in use'); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/measurement/spectrum', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/measurement/spectrum', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }) + }); + it('returns 404 for an unknown name', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/measurement/xxx', + auth: {basic: 'admin'}, + httpStatus: 404 + }) + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/template/measurement/spectrum', + httpStatus: 401 + }) + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/template.ts b/src/routes/template.ts new file mode 100644 index 0000000..7e4aee7 --- /dev/null +++ b/src/routes/template.ts @@ -0,0 +1,91 @@ +import express from 'express'; + +import TemplateValidate from './validate/template'; +import TemplateTreatmentModel from '../models/treatment_template'; +import TemplateMeasurementModel from '../models/measurement_template'; + + +const router = express.Router(); + +router.get('/template/:collection(measurements|treatments)', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + + (req.params.collection === 'treatments' ? TemplateTreatmentModel : TemplateMeasurementModel) + .find({}).lean().exec((err, data) => { + if (err) next (err); + res.json(data.map(e => TemplateValidate.output(e)).filter(e => e !== null)); // validate all and filter null values from validation errors + }); +}); + +router.get('/template/:collection(measurement|treatment)/:name', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; + + (req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel) + .findOne({name: req.params.name}).lean().exec((err, data) => { + if (err) next (err); + if (data) { + res.json(TemplateValidate.output(data)); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + +router.put('/template/:collection(measurement|treatment)/:name', (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + const collectionModel = req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel; + + collectionModel.findOne({name: req.params.name}).lean().exec((err, data) => { + if (err) next (err); + const templateState = data? 'change': 'new'; + const {error, value: template} = TemplateValidate.input(req.body, templateState); + if(error !== undefined) { + res.status(400).json({status: 'Invalid body format'}); + return; + } + + if (template.hasOwnProperty('name') && template.name !== req.params.name) { + collectionModel.find({name: template.name}).lean().exec((err, data) => { + if (err) next (err); + if (data.length > 0) { + res.status(400).json({status: 'Template name already taken'}); + return; + } + else { + f(); + } + }); + } + else { + f(); + } + + function f() { // to resolve async + collectionModel.findOneAndUpdate({name: req.params.name}, template, {new: true, upsert: true}).lean().exec((err, data) => { + if (err) next(err); + res.json(TemplateValidate.output(data)); + }); + } + }); +}); + +router.delete('/template/:collection(measurement|treatment)/:name', (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + (req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel) + .findOneAndDelete({name: req.params.name}).lean().exec((err, data) => { + if (err) next(err); + if (data) { + res.json({status: 'OK'}) + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + + + +module.exports = router; \ No newline at end of file diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts new file mode 100644 index 0000000..6a0a23f --- /dev/null +++ b/src/routes/validate/template.ts @@ -0,0 +1,59 @@ +import joi from '@hapi/joi'; +import IdValidate from './id'; + +export default class TemplateValidate { + private static template = { + name: joi.string() + .max(128), + + parameters: joi.array() + .min(1) + .items( + joi.object({ + name: joi.string() + .max(128) + .required(), + + range: joi.object({ + values: joi.array() + .min(1), + + min: joi.number(), + + max: joi.number() + }) + .oxor('values', 'min') + .oxor('values', 'max') + .required() + }) + ) + }; + + static input (data, param) { // validate data, param: new(everything required)/change(available attributes are validated) + if (param === 'new') { + return joi.object({ + name: this.template.name.required(), + parameters: this.template.parameters.required() + }).validate(data); + } + else if (param === 'change') { + return joi.object({ + name: this.template.name, + parameters: this.template.parameters + }).validate(data); + } + else { + return{error: 'No parameter specified!', value: {}}; + } + } + + static output (data) { // validate output from database for needed properties, strip everything else + data._id = data._id.toString(); + const {value, error} = joi.object({ + _id: IdValidate.get(), + name: this.template.name, + parameters: this.template.parameters + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/test/db.json b/src/test/db.json index 0b4fd2f..d1bca35 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -84,6 +84,72 @@ ], "__v": 0 } + ], + "treatment_templates": [ + { + "_id": {"$oid":"200000000000000000000001"}, + "name": "heat treatment", + "parameters": [ + { + "name": "material", + "range": { + "values": [ + "copper", + "hot air" + ] + } + }, + { + "name": "weeks", + "range": { + "min": 1, + "max": 10 + } + } + ] + }, + { + "_id": {"$oid":"200000000000000000000002"}, + "name": "heat treatment 2", + "parameters": [ + { + "name": "material", + "range": {} + } + ] + } + ], + "measurement_templates": [ + { + "_id": {"$oid":"300000000000000000000001"}, + "name": "spectrum", + "parameters": [ + { + "name": "dpt", + "range": {} + } + ] + }, + { + "_id": {"$oid":"300000000000000000000002"}, + "name": "kf", + "parameters": [ + { + "name": "weight %", + "range": { + "min": 0, + "max": 1.5 + } + }, + { + "name": "standard deviation", + "range": { + "min": 0, + "max": 0.5 + } + } + ] + } ] } } \ No newline at end of file diff --git a/static/styles/swagger.css b/static/styles/swagger.css index ac69d38..33bebe1 100644 --- a/static/styles/swagger.css +++ b/static/styles/swagger.css @@ -144,6 +144,13 @@ body:after { border-color: var(--red); } +/*download button*/ +.swagger-ui .download-contents { + border-radius: 0; + height: 28px; + width: 80px; +} + /*model*/ .swagger-ui .model-box { border-radius: 0; @@ -153,6 +160,22 @@ body:after { .swagger-ui .btn.execute { background-color: var(--dark-blue); border-color: var(--dark-blue); + height: 30px; + line-height: 0.7; +} + +.swagger-ui .btn-group .btn:last-child { + border-radius: 0; + height: 30px; + border-color: var(--dark-blue); +} + +.swagger-ui .btn-group .btn:first-child { + border-radius: 0; +} + +.swagger-ui .btn-group { + padding: 0 20px; } /*parameter input*/ @@ -160,6 +183,14 @@ body:after { border-radius: 0; } +/*required label*/ +.swagger-ui .parameter__name.required > span { + color: var(--red) !important; +} + +.swagger-ui .parameter__name.required::after { + color: var(--red); +} /*Remove colored parameters bar*/ .swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after { background: none; From 20f57acd2aa031a3fbce7b4f61f6a64749d98606 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Wed, 6 May 2020 14:39:04 +0200 Subject: [PATCH 16/83] implemented first /sample methods --- api/sample.yaml | 41 +++- api/schemas.yaml | 25 ++- src/db.ts | 6 +- src/helpers/authorize.ts | 15 +- src/helpers/test.ts | 1 + src/index.ts | 3 +- src/models/note.ts | 12 ++ src/models/note_field.ts | 8 + src/models/sample.ts | 18 ++ src/routes/material.spec.ts | 11 +- src/routes/material.ts | 19 +- src/routes/sample.spec.ts | 336 ++++++++++++++++++++++++++++++ src/routes/sample.ts | 109 ++++++++++ src/routes/template.spec.ts | 1 - src/routes/template.ts | 7 +- src/routes/user.ts | 29 ++- src/routes/validate/id.ts | 11 +- src/routes/validate/material.ts | 2 +- src/routes/validate/note_field.ts | 18 ++ src/routes/validate/sample.ts | 77 +++++++ src/routes/validate/template.ts | 2 +- src/routes/validate/user.ts | 2 +- src/test/db.json | 178 +++++++++++++--- tsconfig.json | 2 + 24 files changed, 844 insertions(+), 89 deletions(-) create mode 100644 src/models/note.ts create mode 100644 src/models/note_field.ts create mode 100644 src/models/sample.ts create mode 100644 src/routes/sample.spec.ts create mode 100644 src/routes/sample.ts create mode 100644 src/routes/validate/note_field.ts create mode 100644 src/routes/validate/sample.ts diff --git a/api/sample.yaml b/api/sample.yaml index e127d74..32bb6ed 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -1,6 +1,6 @@ /samples: get: - summary: TODO all samples in overview + summary: all samples in overview description: 'Auth: all, levels: read, write, maintain, dev, admin' tags: - /sample @@ -10,7 +10,9 @@ content: application/json: schema: - $ref: 'api.yaml#/components/schemas/Samples' + type: array + items: + $ref: 'api.yaml#/components/schemas/SampleRefs' 401: $ref: 'api.yaml#/components/responses/401' 500: @@ -39,7 +41,7 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: TODO add/change sample + summary: TODO change sample description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /sample @@ -88,10 +90,41 @@ $ref: 'api.yaml#/components/responses/404' 500: $ref: 'api.yaml#/components/responses/500' + +/sample/new: + post: + summary: add sample + description: 'Auth: basic, levels: write, maintain, dev, admin' + tags: + - /sample + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Sample' + responses: + 200: + description: samples details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/SampleRefs' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' + /sample/notes/fields: get: summary: TODO list all existing field names for custom notes fields - description: 'Auth: all, levels: write, maintain, dev, admin' + description: 'Auth: all, levels: read, write, maintain, dev, admin' tags: - /sample responses: diff --git a/api/schemas.yaml b/api/schemas.yaml index 62b4690..a7aa0e2 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -14,16 +14,17 @@ Color: example: black SampleProperties: properties: - sample_number: + number: type: string + example: Rng172 type: type: string + example: granulate batch: type: string - validated: - type: boolean + example: 1560237365 -Samples: +SampleRefs: allOf: - $ref: 'api.yaml#/components/schemas/_Id' - $ref: 'api.yaml#/components/schemas/Color' @@ -41,17 +42,23 @@ Sample: - $ref: 'api.yaml#/components/schemas/Color' - $ref: 'api.yaml#/components/schemas/SampleProperties' properties: - material: - $ref: 'api.yaml#/components/schemas/Material' + material_id: + allOf: + - $ref: 'api.yaml#/components/schemas/Id' notes: type: object properties: - comments: + comment: type: string sample_references: type: array items: - $ref: 'api.yaml#/components/schemas/Id' + properties: + id: + $ref: 'api.yaml#/components/schemas/Id' + relation: + type: string + example: part to this sample SampleDetail: allOf: - $ref: 'api.yaml#/components/schemas/_Id' @@ -63,7 +70,7 @@ SampleDetail: notes: type: object properties: - comments: + comment: type: string sample_references: type: array diff --git a/src/db.ts b/src/db.ts index 090e275..f188468 100644 --- a/src/db.ts +++ b/src/db.ts @@ -37,7 +37,7 @@ export default class db { } // connect to db - mongoose.connect(connectionString, {useNewUrlParser: true, useUnifiedTopology: true, useCreateIndex: true}, err => { + mongoose.connect(connectionString, {useNewUrlParser: true, useUnifiedTopology: true, useCreateIndex: true, connectTimeoutMS: 10000}, err => { if (err) done(err); }); mongoose.connection.on('error', console.error.bind(console, 'connection error:')); @@ -92,7 +92,9 @@ export default class db { Object.keys(json.collections).forEach(collectionName => { // create each collection for(let i in json.collections[collectionName]) { // convert $oid fields to actual ObjectIds Object.keys(json.collections[collectionName][i]).forEach(key => { - json.collections[collectionName][i][key] = json.collections[collectionName][i][key].hasOwnProperty('$oid') ? mongoose.Types.ObjectId(json.collections[collectionName][i][key].$oid) : json.collections[collectionName][i][key]; + if (json.collections[collectionName][i][key] !== null && json.collections[collectionName][i][key].hasOwnProperty('$oid')) { + json.collections[collectionName][i][key] = mongoose.Types.ObjectId(json.collections[collectionName][i][key].$oid); + } }) } this.state.db.createCollection(collectionName, (err, collection) => { diff --git a/src/helpers/authorize.ts b/src/helpers/authorize.ts index d3c7e75..e2f626a 100644 --- a/src/helpers/authorize.ts +++ b/src/helpers/authorize.ts @@ -9,7 +9,7 @@ import UserModel from '../models/user'; module.exports = async (req, res, next) => { let givenMethod = ''; // authorization method given by client, basic taken preferred - let user = {name: '', level: ''}; // user object + let user = {name: '', level: '', id: ''}; // user object // test authentications const userBasic = await basic(req, next); @@ -45,7 +45,8 @@ module.exports = async (req, res, next) => { req.authDetails = { method: givenMethod, username: user.name, - level: user.level + level: user.level, + id: user.id }; next(); @@ -57,12 +58,12 @@ function basic (req, next): any { // checks basic auth and returns changed user const auth = basicAuth(req); if (auth !== undefined) { // basic auth available UserModel.find({name: auth.name}).lean().exec( (err, data: any) => { // find user - if (err) next(err); + if (err) return next(err); if (data.length === 1) { // one user found bcrypt.compare(auth.pass, data[0].pass, (err, res) => { // check password - if (err) next(err); + if (err) return next(err); if (res === true) { - resolve({level: data[0].level, name: data[0].name}); + resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString()}); } else { resolve(null); @@ -84,9 +85,9 @@ function key (req, next): any { // checks API key and returns changed user obje return new Promise(resolve => { if (req.query.key !== undefined) { UserModel.find({key: req.query.key}).lean().exec( (err, data: any) => { // find user - if (err) next(err); + if (err) return next(err); if (data.length === 1) { // one user found - resolve({level: data[0].level, name: data[0].name}); + resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString()}); } else { resolve(null); diff --git a/src/helpers/test.ts b/src/helpers/test.ts index afd49dd..6c2fa72 100644 --- a/src/helpers/test.ts +++ b/src/helpers/test.ts @@ -14,6 +14,7 @@ export default class TestHelper { 401: {status: 'Unauthorized'}, 403: {status: 'Forbidden'}, 404: {status: 'Not found'}, + 500: {status: 'Internal server error'} } static before (done) { diff --git a/src/index.ts b/src/index.ts index 15bd504..63ca19e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,9 +45,10 @@ app.use(require('./helpers/authorize')); // handle authentication // require routes app.use('/', require('./routes/root')); -app.use('/', require('./routes/user')); +app.use('/', require('./routes/sample')); app.use('/', require('./routes/material')); app.use('/', require('./routes/template')); +app.use('/', require('./routes/user')); // static files app.use('/static', express.static('static')); diff --git a/src/models/note.ts b/src/models/note.ts new file mode 100644 index 0000000..a13fd6a --- /dev/null +++ b/src/models/note.ts @@ -0,0 +1,12 @@ +import mongoose from 'mongoose'; + +const NoteSchema = new mongoose.Schema({ + comment: String, + sample_references: [{ + id: mongoose.Schema.Types.ObjectId, + relation: String + }], + custom_fields: mongoose.Schema.Types.Mixed +}); + +export default mongoose.model('note', NoteSchema); \ No newline at end of file diff --git a/src/models/note_field.ts b/src/models/note_field.ts new file mode 100644 index 0000000..86158e3 --- /dev/null +++ b/src/models/note_field.ts @@ -0,0 +1,8 @@ +import mongoose from 'mongoose'; + +const NoteFieldSchema = new mongoose.Schema({ + name: {type: String, index: {unique: true}}, + qty: Number +}); + +export default mongoose.model('note_field', NoteFieldSchema); \ No newline at end of file diff --git a/src/models/sample.ts b/src/models/sample.ts new file mode 100644 index 0000000..81dcc28 --- /dev/null +++ b/src/models/sample.ts @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; + +import MaterialModel from './material'; +import NoteModel from './note'; +import UserModel from './user'; + +const SampleSchema = new mongoose.Schema({ + number: {type: String, index: {unique: true}}, + type: String, + color: String, + batch: String, + validated: Boolean, + material_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialModel}, + note_id: {type: mongoose.Schema.Types.ObjectId, ref: NoteModel}, + user_id: {type: mongoose.Schema.Types.ObjectId, ref: UserModel} +}); + +export default mongoose.model('sample', SampleSchema); \ No newline at end of file diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index c69538a..7b84c08 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -19,7 +19,7 @@ describe('/material', () => { }).end((err, res) => { if (err) return done(err); const json = require('../test/db.json'); - should(res.body).have.lengthOf(json.collections.users.length); + should(res.body).have.lengthOf(json.collections.materials.length); should(res.body).matchEach(material => { should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); should(material).have.property('_id').be.type('string'); @@ -47,7 +47,7 @@ describe('/material', () => { }).end((err, res) => { if (err) return done(err); const json = require('../test/db.json'); - should(res.body).have.lengthOf(json.collections.users.length); + should(res.body).have.lengthOf(json.collections.materials.length); should(res.body).matchEach(material => { should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); should(material).have.property('_id').be.type('string'); @@ -82,7 +82,7 @@ describe('/material', () => { url: '/material/100000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}]} + res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}, {color: 'natural', number: 5514263422}]} }); }); it('returns the right material for an API key', done => { @@ -127,7 +127,7 @@ describe('/material', () => { auth: {basic: 'janedoe'}, httpStatus: 200, req: {}, - res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}]} + res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}, {color: 'natural', number: 5514263422}]} }); }); it('keeps unchanged properties', done => { @@ -296,7 +296,6 @@ describe('/material', () => { req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]} }).end((err, res) => { if (err) return done (err); - console.log(res.body); should(res.body).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); should(res.body).have.property('_id').be.type('string'); should(res.body).have.property('name', 'Crastin CE 2510'); @@ -324,7 +323,7 @@ describe('/material', () => { if (err) return done (err); MaterialModel.find({name: 'Crastin CE 2510'}).lean().exec((err, data: any) => { if (err) return done (err); - console.log(data[0]); + should(data).have.lengthOf(1); should(data[0]).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', '__v'); should(data[0]).have.property('_id'); should(data[0]).have.property('name', 'Crastin CE 2510'); diff --git a/src/routes/material.ts b/src/routes/material.ts index c44afa7..5628fa6 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -11,7 +11,7 @@ router.get('/materials', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; MaterialModel.find({}).lean().exec((err, data) => { - if(err) next(err); + if (err) return next(err); res.json(data.map(e => MaterialValidate.output(e)).filter(e => e !== null)); // validate all and filter null values from validation errors }); }); @@ -20,8 +20,7 @@ router.get('/material/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; MaterialModel.findById(req.params.id).lean().exec((err, data) => { - if(err) next(err); - console.log(data); + if (err) return next(err); if (data) { res.json(MaterialValidate.output(data)); } @@ -35,14 +34,14 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; const {error, value: material} = MaterialValidate.input(req.body, 'change'); - if(error !== undefined) { + if (error) { res.status(400).json({status: 'Invalid body format'}); return; } if (material.hasOwnProperty('name')) { MaterialModel.find({name: material.name}).lean().exec((err, data) => { - if(err) next(err); + if (err) return next(err); if (data.length > 0 && data[0]._id != req.params.id) { res.status(400).json({status: 'Material name already taken'}); return; @@ -58,7 +57,7 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { function f() { // to resolve async MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).lean().exec((err, data) => { - if (err) next(err); + if (err) return next(err); if (data) { res.json(MaterialValidate.output(data)); } @@ -73,7 +72,7 @@ router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; MaterialModel.findByIdAndDelete(req.params.id).lean().exec((err, data) => { - if (err) next(err); + if (err) return next(err); if (data) { res.json({status: 'OK'}) } @@ -88,20 +87,20 @@ router.post('/material/new', (req, res, next) => { // validate input const {error, value: material} = MaterialValidate.input(req.body, 'new'); - if(error !== undefined) { + if (error) { res.status(400).json({status: 'Invalid body format'}); return; } MaterialModel.find({name: material.name}).lean().exec((err, data) => { - if(err) next(err); + if (err) return next(err); if (data.length > 0) { res.status(400).json({status: 'Material name already taken'}); return; } new MaterialModel(material).save((err, data) => { - if(err) next(err); + if (err) return next(err); res.json(MaterialValidate.output(data.toObject())); }); }); diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts new file mode 100644 index 0000000..857556c --- /dev/null +++ b/src/routes/sample.spec.ts @@ -0,0 +1,336 @@ +import should from 'should/as-function'; +import SampleModel from '../models/sample'; +import NoteModel from '../models/note'; +import NoteFieldModel from '../models/note_field'; +import TestHelper from "../helpers/test"; + + +describe('/sample', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + + describe('GET /samples', () => { + it('returns all samples', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.samples.length); + should(res.body).matchEach(material => { + should(material).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); + should(material).have.property('_id').be.type('string'); + should(material).have.property('number').be.type('string'); + should(material).have.property('type').be.type('string'); + should(material).have.property('color').be.type('string'); + should(material).have.property('batch').be.type('string'); + should(material).have.property('material_id').be.type('string'); + should(material).have.property('note_id'); + should(material).have.property('user_id').be.type('string'); + }); + done(); + }); + }); + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples', + auth: {key: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.samples.length); + should(res.body).matchEach(material => { + should(material).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); + should(material).have.property('_id').be.type('string'); + should(material).have.property('number').be.type('string'); + should(material).have.property('type').be.type('string'); + should(material).have.property('color').be.type('string'); + should(material).have.property('batch').be.type('string'); + should(material).have.property('material_id').be.type('string'); + should(material).have.property('note_id'); + should(material).have.property('user_id').be.type('string'); + }); + done(); + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples', + httpStatus: 401 + }); + }); + }); + + describe('POST /sample/new', () => { + it('returns the right sample', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('number', 'Rng172'); + should(res.body).have.property('color', 'black'); + should(res.body).have.property('type', 'granulate'); + should(res.body).have.property('batch', '1560237365'); + should(res.body).have.property('material_id', '100000000000000000000001'); + should(res.body).have.property('note_id').be.type('string'); + should(res.body).have.property('user_id', '000000000000000000000002'); + done(); + }); + }); + it('stores the sample', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + }).end(err => { + if (err) return done (err); + SampleModel.find({number: 'Rng172'}).lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', '__v'); + should(data[0]).have.property('_id'); + should(data[0]).have.property('number', 'Rng172'); + should(data[0]).have.property('color', 'black'); + should(data[0]).have.property('type', 'granulate'); + should(data[0]).have.property('batch', '1560237365'); + should(data[0].material_id.toString()).be.eql('100000000000000000000001'); + should(data[0].user_id.toString()).be.eql('000000000000000000000002'); + should(data[0]).have.property('note_id'); + NoteModel.findById(data[0].note_id).lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.property('_id'); + should(data).have.property('comment', 'Testcomment'); + should(data).have.property('sample_references'); + should(data.sample_references).have.lengthOf(1); + should(data.sample_references[0].id.toString()).be.eql('400000000000000000000003'); + should(data.sample_references[0]).have.property('relation', 'part to this sample'); + done(); + }); + }) + }); + }); + it('stores the custom fields', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [], custom_fields: {field1: 'a', field2: 'b', 'not allowed for new applications': true}}} + }).end((err, res) => { + if (err) return done (err); + NoteModel.findById(res.body.note_id).lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('_id'); + should(data).have.property('comment', 'Testcomment'); + should(data).have.property('sample_references').have.lengthOf(0); + should(data).have.property('custom_fields'); + should(data.custom_fields).have.property('field1', 'a'); + should(data.custom_fields).have.property('field2', 'b'); + should(data.custom_fields).have.property('not allowed for new applications', true); + NoteFieldModel.find({name: 'field1'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.property('qty', 1); + NoteFieldModel.find({name: 'field2'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.property('qty', 1); + NoteFieldModel.find({name: 'not allowed for new applications'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.property('qty', 3); + done(); + }); + }); + }); + }); + }); + }); + it('rejects a color not defined for the material', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: 'Rng172', color: 'green', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Color not available for material'} + }); + }); + it('rejects an unknown material id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '000000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Material not available'} + }); + }); + it('rejects a sample number in use', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: '1', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Sample number already taken'} + }); + }); + it('rejects an invalid sample reference', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '000000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Sample reference not available'} + }); + }); + it('rejects a missing color', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: 'Rng172', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects a missing sample number', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects a missing type', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: 'Rng172', color: 'black', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects a missing batch', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: 'Rng172', color: 'black', type: 'granulate', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects a missing material id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects an invalid material id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'user'}, + httpStatus: 403, + req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + httpStatus: 401, + req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + }); + }); + }); + + describe('GET /sample/notes/fields', () => { + it('returns all fields', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/notes/fields', + auth: {basic: 'user'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.note_fields.length); + should(res.body).matchEach(material => { + should(material).have.only.keys('name', 'qty'); + should(material).have.property('qty').be.type('number'); + }); + done(); + }); + }); + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/notes/fields', + auth: {key: 'user'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.note_fields.length); + should(res.body).matchEach(material => { + should(material).have.only.keys('name', 'qty'); + should(material).have.property('qty').be.type('number'); + }); + done(); + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/notes/fields', + httpStatus: 401 + }); + }); + }); +}); diff --git a/src/routes/sample.ts b/src/routes/sample.ts new file mode 100644 index 0000000..bbebaba --- /dev/null +++ b/src/routes/sample.ts @@ -0,0 +1,109 @@ +import express from 'express'; + +import SampleValidate from './validate/sample'; +import NoteFieldValidate from './validate/note_field'; +import SampleModel from '../models/sample' +import MaterialModel from '../models/material'; +import NoteModel from '../models/note'; +import NoteFieldModel from '../models/note_field'; + + + +const router = express.Router(); + +router.get('/samples', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + SampleModel.find({}).lean().exec((err, data) => { + if (err) return next(err); + res.json(data.map(e => SampleValidate.output(e)).filter(e => e !== null)); // validate all and filter null values from validation errors + }) +}); + + +router.post('/sample/new', (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const {error, value: sample} = SampleValidate.input(req.body, 'new'); + if (error) { + return res.status(400).json({status: 'Invalid body format'}); + } + + MaterialModel.findById(sample.material_id).lean().exec((err, data: any) => { // validate material_id + if (err) return next(err); + if (!data) { // could not find material_id + return res.status(400).json({status: 'Material not available'}); + } + if (!data.numbers.find(e => e.color === sample.color)) { // color for material not specified + return res.status(400).json({status: 'Color not available for material'}); + } + SampleModel.findOne({number: sample.number}).lean().exec((err, data) => { // validate sample number + if (err) return next(err); + if (data) { // found entry with sample number + return res.status(400).json({status: 'Sample number already taken'}); + } + + if (sample.notes.sample_references.length > 0) { // validate sample_references + let referencesCount = sample.notes.sample_references.length; + sample.notes.sample_references.forEach(reference => { + SampleModel.findById(reference.id).lean().exec((err, data) => { + if (err) return next(err); + if (!data) { + return res.status(400).json({status: 'Sample reference not available'}); + } + referencesCount --; + if (referencesCount <= 0) { + f(); + } + }); + }); + } + else { + f(); + } + + if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { + customFieldsAdd(Object.keys(sample.notes.custom_fields)); + } + + function f() { // to resolve async + new NoteModel(sample.notes).save((err, data) => { + if (err) return next(err); + delete sample.notes; + sample.note_id = data._id; + sample.user_id = req.authDetails.id; + new SampleModel(sample).save((err, data) => { + if (err) return next(err); + res.json(SampleValidate.output(data.toObject())); + }); + }); + } + }); + }) +}); + +router.get('/sample/notes/fields', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + NoteFieldModel.find({}).lean().exec((err, data) => { + if (err) return next(err); + res.json(data.map(e => NoteFieldValidate.output(e)).filter(e => e !== null)); // validate all and filter null values from validation errors + }) +}); + + +module.exports = router; + + +function customFieldsAdd (fields) { + fields.forEach(field => { + NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: 1}}).lean().exec((err, data) => { // check if field exists + if (err) return console.error(err); + if (!data) { // new field + new NoteFieldModel({name: field, qty: 1}).save(err => { + if (err) return console.error(err); + }) + } + }); + }); +} \ No newline at end of file diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index 5ee4d1a..68b3d4a 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -172,7 +172,6 @@ describe('/template', () => { if (err) return done(err); TemplateTreatmentModel.find({name: 'heat aging'}).lean().exec((err, data:any) => { if (err) return done(err); - console.log(data); should(data).have.lengthOf(1); should(data[0]).have.only.keys('_id', 'name', 'parameters', '__v'); should(data[0]).have.property('name', 'heat aging'); diff --git a/src/routes/template.ts b/src/routes/template.ts index 7e4aee7..1e859cd 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -41,7 +41,7 @@ router.put('/template/:collection(measurement|treatment)/:name', (req, res, next if (err) next (err); const templateState = data? 'change': 'new'; const {error, value: template} = TemplateValidate.input(req.body, templateState); - if(error !== undefined) { + if (error) { res.status(400).json({status: 'Invalid body format'}); return; } @@ -64,7 +64,7 @@ router.put('/template/:collection(measurement|treatment)/:name', (req, res, next function f() { // to resolve async collectionModel.findOneAndUpdate({name: req.params.name}, template, {new: true, upsert: true}).lean().exec((err, data) => { - if (err) next(err); + if (err) return next(err); res.json(TemplateValidate.output(data)); }); } @@ -76,7 +76,7 @@ router.delete('/template/:collection(measurement|treatment)/:name', (req, res, n (req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel) .findOneAndDelete({name: req.params.name}).lean().exec((err, data) => { - if (err) next(err); + if (err) return next(err); if (data) { res.json({status: 'OK'}) } @@ -87,5 +87,4 @@ router.delete('/template/:collection(measurement|treatment)/:name', (req, res, n }); - module.exports = router; \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts index c60dd7b..a0161f9 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -27,7 +27,7 @@ router.get('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi } UserModel.findOne({name: username}).lean().exec( (err, data:any) => { - if (err) next(err); + if (err) return next(err); if (data) { res.json(UserValidate.output(data)); // validate all and filter null values from validation errors } @@ -46,7 +46,7 @@ router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi username = req.params.username; } const {error, value: user} = UserValidate.input(req.body, 'change' + (req.authDetails.level === 'admin'? 'admin' : '')); - if(error !== undefined) { + if (error) { res.status(400).json({status: 'Invalid body format'}); return; } @@ -58,14 +58,14 @@ router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi // check that user does not already exist if new name was specified if (user.hasOwnProperty('name') && user.name !== username) { UserModel.find({name: user.name}).lean().exec( (err, data:any) => { - if (err) next(err); + if (err) return next(err); if (data.length > 0 || UserValidate.isSpecialName(user.name)) { res.status(400).json({status: 'Username already taken'}); return; } UserModel.findOneAndUpdate({name: username}, user, {new: true}).lean().exec( (err, data:any) => { - if (err) next(err); + if (err) return next(err); if (data) { res.json(UserValidate.output(data)); } @@ -77,7 +77,7 @@ router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi } else { UserModel.findOneAndUpdate({name: username}, user, {new: true}).lean().exec( (err, data:any) => { - if (err) next(err); + if (err) return next(err); if (data) { res.json(UserValidate.output(data)); // validate all and filter null values from validation errors } @@ -98,7 +98,7 @@ router.delete('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // } UserModel.findOneAndDelete({name: username}).lean().exec( (err, data:any) => { - if (err) next(err); + if (err) return next(err); if (data) { res.json({status: 'OK'}) } @@ -109,11 +109,10 @@ router.delete('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // }); router.get('/user/key', (req, res, next) => { - console.log('hmm'); if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; UserModel.findOne({name: req.authDetails.username}).lean().exec( (err, data:any) => { - if (err) next(err); + if (err) return next(err); res.json({key: data.key}); }); }); @@ -123,14 +122,14 @@ router.post('/user/new', (req, res, next) => { // validate input const {error, value: user} = UserValidate.input(req.body, 'new'); - if(error !== undefined) { + if (error) { res.status(400).json({status: 'Invalid body format'}); return; } // check that user does not already exist UserModel.find({name: user.name}).lean().exec( (err, data:any) => { - if (err) next(err); + if (err) return next(err); if (data.length > 0 || UserValidate.isSpecialName(user.name)) { res.status(400).json({status: 'Username already taken'}); return; @@ -140,7 +139,7 @@ router.post('/user/new', (req, res, next) => { bcrypt.hash(user.pass, 10, (err, hash) => { // password hashing user.pass = hash; new UserModel(user).save((err, data) => { // store user - if (err) next(err); + if (err) return next(err); res.json(UserValidate.output(data.toObject())); }); }); @@ -150,15 +149,15 @@ router.post('/user/new', (req, res, next) => { router.post('/user/passreset', (req, res, next) => { // check if user/email combo exists UserModel.find({name: req.body.name, email: req.body.email}).lean().exec( (err, data: any) => { - if (err) next(err); + if (err) return next(err); if (data.length === 1) { // it exists const newPass = Math.random().toString(36).substring(2); bcrypt.hash(newPass, 10, (err, hash) => { // password hashing - if (err) next(err); + if (err) return next(err); UserModel.findByIdAndUpdate(data[0]._id, {pass: hash}, err => { // write new password - if (err) next(err); + if (err) return next(err); mail(data[0].email, 'Your new password for the DFOP database', 'Hi,

    You requested to reset your password.
    Your new password is:

    ' + newPass + '

    If you did not request a password reset, talk to the sysadmin quickly!

    Have a nice day.

    The DFOP team', err => { - if (err) next(err); + if (err) return next(err); res.json({status: 'OK'}); }); }); diff --git a/src/routes/validate/id.ts b/src/routes/validate/id.ts index 84024e9..5409993 100644 --- a/src/routes/validate/id.ts +++ b/src/routes/validate/id.ts @@ -11,7 +11,16 @@ export default class IdValidate { return this.id.validate(id).error === undefined; } - static parameter() { // :id url parameter + static parameter () { // :id url parameter return ':id([0-9a-f]{24})'; } + + static stringify (data) { + Object.keys(data).forEach(key => { + if (data[key] !== null && data[key].hasOwnProperty('_bsontype') && data[key]._bsontype === 'ObjectID') { + data[key] = data[key].toString(); + } + }); + return data; + } } \ No newline at end of file diff --git a/src/routes/validate/material.ts b/src/routes/validate/material.ts index c5ac005..54cd749 100644 --- a/src/routes/validate/material.ts +++ b/src/routes/validate/material.ts @@ -66,7 +66,7 @@ export default class MaterialValidate { // validate input for material } static output (data) { // validate output from database for needed properties, strip everything else - data._id = data._id.toString(); + data = IdValidate.stringify(data); const {value, error} = joi.object({ _id: IdValidate.get(), name: this.material.name, diff --git a/src/routes/validate/note_field.ts b/src/routes/validate/note_field.ts new file mode 100644 index 0000000..4892f22 --- /dev/null +++ b/src/routes/validate/note_field.ts @@ -0,0 +1,18 @@ +import joi from '@hapi/joi'; + +export default class NoteFieldValidate { + private static note_field = { + name: joi.string() + .max(128), + + qty: joi.number() + }; + + static output (data) { + const {value, error} = joi.object({ + name: this.note_field.name, + qty: this.note_field.qty + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts new file mode 100644 index 0000000..d94cede --- /dev/null +++ b/src/routes/validate/sample.ts @@ -0,0 +1,77 @@ +import joi from '@hapi/joi'; + +import IdValidate from './id'; + +export default class SampleValidate { + private static sample = { + number: joi.string() + .max(128), + + color: joi.string() + .max(128), + + type: joi.string() + .max(128), + + batch: joi.string() + .max(128) + .allow(''), + + notes: joi.object({ + comment: joi.string() + .max(512), + + sample_references: joi.array() + .items(joi.object({ + id: IdValidate.get(), + + relation: joi.string() + .max(128) + })), + + custom_fields: joi.object() + .pattern(/.*/, joi.alternatives() + .try( + joi.string().max(128), + joi.number(), + joi.boolean(), + joi.date() + ) + ) + }) + }; + + static input (data, param) { // validate data, param: new(everything required)/change(available attributes are validated) + if (param === 'new') { + return joi.object({ + number: this.sample.number.required(), + color: this.sample.color.required(), + type: this.sample.type.required(), + batch: this.sample.batch.required(), + material_id: IdValidate.get().required(), + notes: this.sample.notes.required() + }).validate(data); + } + else if (param === 'change') { + return{error: 'Not implemented!', value: {}}; + } + else { + return{error: 'No parameter specified!', value: {}}; + } + } + + static output (data) { + data = IdValidate.stringify(data); + const {value, error} = joi.object({ + _id: IdValidate.get(), + number: this.sample.number, + color: this.sample.color, + type: this.sample.type, + batch: this.sample.batch, + material_id: IdValidate.get(), + note_id: IdValidate.get().allow(null), + user_id: IdValidate.get() + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts index 6a0a23f..a279dce 100644 --- a/src/routes/validate/template.ts +++ b/src/routes/validate/template.ts @@ -48,7 +48,7 @@ export default class TemplateValidate { } static output (data) { // validate output from database for needed properties, strip everything else - data._id = data._id.toString(); + data = IdValidate.stringify(data); const {value, error} = joi.object({ _id: IdValidate.get(), name: this.template.name, diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index 4b1259a..150bf64 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -69,7 +69,7 @@ export default class UserValidate { // validate input for user } static output (data) { // validate output from database for needed properties, strip everything else - data._id = data._id.toString(); + data = IdValidate.stringify(data); const {value, error} = joi.object({ _id: IdValidate.get(), name: this.user.name, diff --git a/src/test/db.json b/src/test/db.json index d1bca35..2d8a7d0 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -1,38 +1,93 @@ { "collections": { - "users": [ + "samples": [ { - "_id": {"$oid":"000000000000000000000001"}, - "email": "user@bosch.com", - "name": "user", - "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi", - "level": "read", - "location": "Rng", - "device_name": "Alpha I", - "key": "000000000000000000001001", + "_id": {"$oid":"400000000000000000000001"}, + "number": "1", + "type": "granulate", + "color": "black", + "batch": "", + "validated": true, + "material_id": {"$oid":"100000000000000000000004"}, + "note_id": null, + "user_id": {"$oid":"000000000000000000000002"}, "__v": 0 }, { - "_id": {"$oid":"000000000000000000000002"}, - "email": "jane.doe@bosch.com", - "name": "janedoe", - "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi", - "level": "write", - "location": "Rng", - "device_name": "Alpha I", - "key": "000000000000000000001002", + "_id": {"$oid":"400000000000000000000002"}, + "number": "21", + "type": "granulate", + "color": "natural", + "batch": "1560237365", + "validated": true, + "material_id": {"$oid":"100000000000000000000001"}, + "note_id": {"$oid":"500000000000000000000001"}, + "user_id": {"$oid":"000000000000000000000002"}, "__v": 0 }, { - "_id": {"$oid":"000000000000000000000003"}, - "email": "a.d.m.i.n@bosch.com", - "name": "admin", - "pass": "$2a$10$i872o3qR5V3JnbDArD8Z.eDo.BNPDBaR7dUX9KSEtl9pUjLyucy2K", - "level": "admin", - "location": "Rng", - "device_name": "", - "key": "000000000000000000001003", - "__v": "0" + "_id": {"$oid":"400000000000000000000003"}, + "number": "33", + "type": "part", + "color": "black", + "batch": "1704-005", + "validated": false, + "material_id": {"$oid":"100000000000000000000005"}, + "note_id": {"$oid":"500000000000000000000002"}, + "user_id": {"$oid":"000000000000000000000003"}, + "__v": 0 + }, + { + "_id": {"$oid":"400000000000000000000004"}, + "number": "32", + "type": "granulate", + "color": "black", + "batch": "1653000308", + "validated": false, + "material_id": {"$oid":"100000000000000000000005"}, + "note_id": {"$oid":"500000000000000000000003"}, + "user_id": {"$oid":"000000000000000000000003"}, + "__v": 0 + } + ], + "notes": [ + { + "_id": {"$oid":"500000000000000000000001"}, + "comment": "Stoff gesperrt", + "sample_references": [], + "__v": 0 + }, + { + "_id": {"$oid":"500000000000000000000002"}, + "comment": "", + "sample_references": [{ + "id": "400000000000000000000004", + "relation": "granulate to sample" + }], + "custom_fields": { + "not allowed for new applications": true + }, + "__v": 0 + }, + { + "_id": {"$oid":"500000000000000000000003"}, + "comment": "", + "sample_references": [{ + "id": "400000000000000000000003", + "relation": "part to sample" + }], + "custom_fields": { + "not allowed for new applications": true + }, + "__v": 0 + } + ], + "note_fields": [ + { + "_id": {"$oid":"600000000000000000000001"}, + "name": "not allowed for new applications", + "qty": 2, + "__v": 0 } ], "materials": [ @@ -48,6 +103,10 @@ { "color": "black", "number": 5514263423 + }, + { + "color": "natural", + "number": 5514263422 } ], "__v": 0 @@ -83,6 +142,38 @@ "numbers": [ ], "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000004"}, + "name": "Schulamid 66 GF 25 H", + "supplier": "Schulmann", + "group": "PA66", + "mineral": 0, + "glass_fiber": 25, + "carbon_fiber": 0, + "numbers": [ + { + "color": "black", + "number": 5513933405 + } + ], + "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000005"}, + "name": "Amodel A 1133 HS", + "supplier": "Solvay", + "group": "PPA", + "mineral": 0, + "glass_fiber": 33, + "carbon_fiber": 0, + "numbers": [ + { + "color": "black", + "number": 5514262406 + } + ], + "__v": 0 } ], "treatment_templates": [ @@ -150,6 +241,41 @@ } ] } + ], + "users": [ + { + "_id": {"$oid":"000000000000000000000001"}, + "email": "user@bosch.com", + "name": "user", + "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi", + "level": "read", + "location": "Rng", + "device_name": "Alpha I", + "key": "000000000000000000001001", + "__v": 0 + }, + { + "_id": {"$oid":"000000000000000000000002"}, + "email": "jane.doe@bosch.com", + "name": "janedoe", + "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi", + "level": "write", + "location": "Rng", + "device_name": "Alpha I", + "key": "000000000000000000001002", + "__v": 0 + }, + { + "_id": {"$oid":"000000000000000000000003"}, + "email": "a.d.m.i.n@bosch.com", + "name": "admin", + "pass": "$2a$10$i872o3qR5V3JnbDArD8Z.eDo.BNPDBaR7dUX9KSEtl9pUjLyucy2K", + "level": "admin", + "location": "Rng", + "device_name": "", + "key": "000000000000000000001003", + "__v": "0" + } ] } } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 8bbe445..b43a5fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,8 @@ "sourceMap": true, "esModuleInterop": true, "resolveJsonModule": true, + "incremental": true, + "diagnostics": true, "typeRoots": [ "src/customTypings", "node_modules/@types" From 63a5f5ebd1eb8204602fd45787dbc7bd31cc852e Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 7 May 2020 21:55:29 +0200 Subject: [PATCH 17/83] implemented more /sample methods --- .idea/dataSources.xml | 11 + .idea/dbnavigator.xml | 458 ++++++++++++++++++++++++++++++ api/sample.yaml | 10 +- src/db.ts | 26 +- src/helpers/mail.ts | 2 +- src/index.ts | 4 +- src/routes/material.spec.ts | 128 ++++++++- src/routes/material.ts | 11 +- src/routes/sample.spec.ts | 393 ++++++++++++++++++++++++- src/routes/sample.ts | 208 ++++++++++---- src/routes/template.spec.ts | 98 ++++++- src/routes/template.ts | 6 +- src/routes/user.spec.ts | 16 +- src/routes/user.ts | 11 +- src/routes/validate/id.ts | 4 +- src/routes/validate/material.ts | 6 +- src/routes/validate/note_field.ts | 8 +- src/routes/validate/res400.ts | 3 + src/routes/validate/sample.ts | 45 +-- src/routes/validate/user.ts | 22 +- src/test/db.json | 13 +- 21 files changed, 1322 insertions(+), 161 deletions(-) create mode 100644 .idea/dataSources.xml create mode 100644 .idea/dbnavigator.xml create mode 100644 src/routes/validate/res400.ts diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..54163ef --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,11 @@ + + + + + mongo + true + com.dbschema.MongoJdbcDriver + mongodb://localhost:27017 + + + \ No newline at end of file diff --git a/.idea/dbnavigator.xml b/.idea/dbnavigator.xml new file mode 100644 index 0000000..ad4eaf6 --- /dev/null +++ b/.idea/dbnavigator.xml @@ -0,0 +1,458 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/api/sample.yaml b/api/sample.yaml index 32bb6ed..8ba92af 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -41,8 +41,8 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: TODO change sample - description: 'Auth: basic, levels: write, maintain, dev, admin' + summary: change sample + description: 'Auth: basic, levels: write, maintain, dev, admin, only maintain and admin are allowed to edit samples created by another user' tags: - /sample security: @@ -59,7 +59,7 @@ content: application/json: schema: - $ref: 'api.yaml#/components/schemas/SampleDetail' + $ref: 'api.yaml#/components/schemas/SampleRefs' 400: $ref: 'api.yaml#/components/responses/400' 401: @@ -71,8 +71,8 @@ 500: $ref: 'api.yaml#/components/responses/500' delete: - summary: TODO delete sample - description: 'Auth: basic, levels: write, maintain, dev, admin' + summary: delete sample + description: 'Auth: basic, levels: write, maintain, dev, admin, only maintain and admin are allowed to edit samples created by another user' tags: - /sample security: diff --git a/src/db.ts b/src/db.ts index f188468..89c3183 100644 --- a/src/db.ts +++ b/src/db.ts @@ -42,19 +42,19 @@ export default class db { }); mongoose.connection.on('error', console.error.bind(console, 'connection error:')); mongoose.connection.on('disconnected', () => { // reset state on disconnect - console.log('Database disconnected'); + console.info('Database disconnected'); this.state.db = 0; done(); }); process.on('SIGINT', () => { // close connection when app is terminated mongoose.connection.close(() => { - console.log('Mongoose default connection disconnected through app termination'); + console.info('Mongoose default connection disconnected through app termination'); process.exit(0); }); }); mongoose.connection.once('open', () => { mongoose.set('useFindAndModify', false); - console.log(process.env.NODE_ENV === 'test' ? '' : `Connected to ${connectionString}`); + console.info(process.env.NODE_ENV === 'test' ? '' : `Connected to ${connectionString}`); this.state.db = mongoose.connection; done(); }); @@ -90,13 +90,7 @@ export default class db { let loadCounter = 0; // count number of loaded collections to know when to return done() Object.keys(json.collections).forEach(collectionName => { // create each collection - for(let i in json.collections[collectionName]) { // convert $oid fields to actual ObjectIds - Object.keys(json.collections[collectionName][i]).forEach(key => { - if (json.collections[collectionName][i][key] !== null && json.collections[collectionName][i][key].hasOwnProperty('$oid')) { - json.collections[collectionName][i][key] = mongoose.Types.ObjectId(json.collections[collectionName][i][key].$oid); - } - }) - } + json.collections[collectionName] = this.oidResolve(json.collections[collectionName]); this.state.db.createCollection(collectionName, (err, collection) => { collection.insertMany(json.collections[collectionName], () => { // insert JSON data if (++ loadCounter >= Object.keys(json.collections).length) { // all collections loaded @@ -106,4 +100,16 @@ export default class db { }); }); } + + private static oidResolve (object: any) { // resolve $oid fields to actual ObjectIds recursively + Object.keys(object).forEach(key => { + if (object[key] !== null && object[key].hasOwnProperty('$oid')) { + object[key] = mongoose.Types.ObjectId(object[key].$oid); + } + else if (typeof object[key] === 'object' && object[key] !== null) { + object[key] = this.oidResolve(object[key]); + } + }); + return object; + } }; \ No newline at end of file diff --git a/src/helpers/mail.ts b/src/helpers/mail.ts index 949d243..792f35f 100644 --- a/src/helpers/mail.ts +++ b/src/helpers/mail.ts @@ -30,7 +30,7 @@ export default (mailAddress, subject, content, f) => { // callback, executed em }); } else if (process.env.NODE_ENV === 'test') { - console.log('Sending mail to ' + mailAddress + ': -- ' + subject + ' -- ' + content); + console.info('Sending mail to ' + mailAddress + ': -- ' + subject + ' -- ' + content); f(); } else { // dev diff --git a/src/index.ts b/src/index.ts index 63ca19e..3a87996 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import db from './db'; // tell if server is running in debug or production environment -console.log(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); +console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); // mongodb connection @@ -75,7 +75,7 @@ app.use((err, req, res, ignore) => { // internal server error handling // hook up server to port const server = app.listen(port, () => { - console.log(process.env.NODE_ENV === 'test' ? '' : `Listening on http://localhost:${port}`); + console.info(process.env.NODE_ENV === 'test' ? '' : `Listening on http://localhost:${port}`); }); module.exports = server; \ No newline at end of file diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 7b84c08..aa9a484 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -171,14 +171,54 @@ describe('/material', () => { res: {status: 'Material name already taken'} }); }); - it('rejects wrong material properties', done => { + it('rejects a wrong mineral property', done => { TestHelper.request(server, done, { method: 'put', url: '/material/100000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {mineral: 'x', glass_fiber: 'x', carbon_fiber: 'x', numbers: [{colorxx: 'black', number: 'xxx'}]}, - res: {status: 'Invalid body format'} + req: {mineral: 'x'}, + res: {status: 'Invalid body format', details: '"mineral" must be a number'} + }); + }); + it('rejects a wrong glass_fiber property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {glass_fiber: 'x'}, + res: {status: 'Invalid body format', details: '"glass_fiber" must be a number'} + }); + }); + it('rejects a wrong carbon_fiber property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {carbon_fiber: 'x'}, + res: {status: 'Invalid body format', details: '"carbon_fiber" must be a number'} + }); + }); + it('rejects a wrong color name property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {numbers: [{colorxx: 'black', number: 55}]}, + res: {status: 'Invalid body format', details: '"numbers[0].color" is required'} + }); + }); + it('rejects a wrong color number property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {numbers: [{color: 'black', number: 'xxx'}]}, + res: {status: 'Invalid body format', details: '"numbers[0].number" must be a number'} }); }); it('rejects an invalid id', done => { @@ -347,24 +387,94 @@ describe('/material', () => { res: {status: 'Material name already taken'} }); }); - it('rejects wrong material properties', done => { + it('rejects a missing name', done => { TestHelper.request(server, done, { method: 'post', url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 'x', glass_fiber: 'x', carbon_fiber: 'x', numbers: [{colorxx: 'black', number: 'xxx'}]}, - res: {status: 'Invalid body format'} + req: {supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"name" is required'} }); }); - it('rejects incomplete material properties', done => { + it('rejects a missing supplier', done => { TestHelper.request(server, done, { method: 'post', url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {name: 'Crastin CE 2510'}, - res: {status: 'Invalid body format'} + req: {name: 'Crastin CE 2510', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"supplier" is required'} + }); + }); + it('rejects a missing group', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"group" is required'} + }); + }); + it('rejects a missing mineral property', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"mineral" is required'} + }); + }); + it('rejects a missing glass_fiber property', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"glass_fiber" is required'} + }); + }); + it('rejects a missing carbon_fiber property', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, numbers: [{color: 'black', number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"carbon_fiber" is required'} + }); + }); + it('rejects a missing numbers array', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0}, + res: {status: 'Invalid body format', details: '"numbers" is required'} + }); + }); + it('rejects a missing color name', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"numbers[0].color" is required'} + }); + }); + it('rejects a missing color number', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black'}]}, + res: {status: 'Invalid body format', details: '"numbers[0].number" is required'} }); }); it('rejects an API key', done => { diff --git a/src/routes/material.ts b/src/routes/material.ts index 5628fa6..29362e2 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -3,6 +3,7 @@ import express from 'express'; import MaterialValidate from './validate/material'; import MaterialModel from '../models/material' import IdValidate from './validate/id'; +import res400 from './validate/res400'; const router = express.Router(); @@ -34,10 +35,7 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; const {error, value: material} = MaterialValidate.input(req.body, 'change'); - if (error) { - res.status(400).json({status: 'Invalid body format'}); - return; - } + if (error) return res400(error, res); if (material.hasOwnProperty('name')) { MaterialModel.find({name: material.name}).lean().exec((err, data) => { @@ -87,10 +85,7 @@ router.post('/material/new', (req, res, next) => { // validate input const {error, value: material} = MaterialValidate.input(req.body, 'new'); - if (error) { - res.status(400).json({status: 'Invalid body format'}); - return; - } + if (error) return res400(error, res); MaterialModel.find({name: material.name}).lean().exec((err, data) => { if (err) return next(err); diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 857556c..98017f0 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -69,6 +69,387 @@ describe('/sample', () => { }); }); + describe('PUT /sample/{id}', () => { + it('returns the right sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {}, + res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} + }); + }); + it('keeps unchanged properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', notes: {}}, + res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} + }); + }); + it('changes the given properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + }).end(err => { + if (err) return done (err); + SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'validated', 'material_id', 'note_id', 'user_id', '__v'); + should(data).have.property('_id'); + should(data).have.property('number', '10'); + should(data).have.property('color', 'signalviolet'); + should(data).have.property('type', 'part'); + should(data).have.property('batch', '114531'); + should(data).have.property('validated').be.type('boolean'); + should(data.material_id.toString()).be.eql('100000000000000000000002'); + should(data.user_id.toString()).be.eql('000000000000000000000002'); + should(data).have.property('note_id'); + NoteModel.findById(data.note_id).lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.property('_id'); + should(data).have.property('comment', 'Testcomment'); + should(data).have.property('sample_references'); + should(data.sample_references).have.lengthOf(1); + should(data.sample_references[0].id.toString()).be.eql('400000000000000000000003'); + should(data.sample_references[0]).have.property('relation', 'part to this sample'); + done(); + }); + }) + }); + }); + it('adjusts the note_fields correctly', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {notes: {comment: 'Testcomment', sample_references: [], custom_fields: {field1: 'value 1'}}} + }).end(err => { + if (err) return done(err); + NoteFieldModel.findOne({name: 'not allowed for new applications'}).lean().exec((err, data) => { + console.log(data); + if (err) return done(err); + should(data).have.property('qty', 1); + NoteFieldModel.findOne({name: 'field1'}).lean().exec((err, data) => { + if (err) return done(err); + console.log(data); + should(data).have.property('qty', 1); + done(); + }); + }); + }); + }); + it('deletes old note_fields', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {notes: {comment: 'Testcomment', sample_references: []}} + }).end(err => { + if (err) return done (err); + NoteFieldModel.findOne({name: 'another_field'}).lean().exec((err, data) => { + if (err) return done (err); + should(data).be.null(); + done(); + }); + }); + }); + it('keeps untouched notes', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {number: '111'} + }).end((err, res) => { + if (err) return done (err); + NoteModel.findById(res.body.note_id).lean().exec((err, data) => { + if (err) return done (err); + console.log(data); + should(data).not.be.null(); + should(data).have.property('comment', 'Stoff gesperrt'); + should(data).have.property('sample_references').have.lengthOf(0); + done(); + }); + }); + }); + it('deletes old notes', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {notes: {comment: 'Testcomment', sample_references: []}} + }).end(err => { + if (err) return done (err); + NoteModel.findById('500000000000000000000003').lean().exec((err, data) => { + if (err) return done (err); + should(data).be.null(); + done(); + }); + }); + }); + it('rejects a color not defined for the material', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Color not available for material'} + }); + }); + it('rejects an unknown material id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '000000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Material not available'} + }); + }); + it('rejects a sample number in use', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: '21', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Sample number already taken'} + }); + }); + it('rejects an invalid sample reference', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '000000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Sample reference not available'} + }); + }); + it('rejects an invalid material id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/10000000000h000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }); + }); + it('rejects changes for samples from another user for a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('accepts changes for samples from another user for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'user'}, + httpStatus: 403, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/000000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + }); + }) + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + httpStatus: 401, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }); + }); + }); + + describe('DELETE /sample/{id}', () => { + it('deletes the sample', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + SampleModel.findById('400000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).be.null(); + done(); + }); + }); + }); + it('deletes the notes of the sample', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + NoteModel.findById('500000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).be.null(); + done(); + }); + }); + }); + it('adjusts the note_fields correctly', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + NoteFieldModel.findOne({name: 'not allowed for new applications'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('qty', 1); + NoteFieldModel.findOne({name: 'another_field'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).be.null(); + done(); + }); + }); + }); + }); + it('resets references to this sample', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + setTimeout(() => { // background action takes some time before we can check + NoteModel.findById('500000000000000000000003').lean().exec((err, data) => { + if (err) return done(err); + console.log(data); + should(data).have.property('sample_references').with.lengthOf(0); + done(); + }); + }, 100); + + }); + }); + it('lets admin/maintain users delete samples of other users', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + SampleModel.findById('400000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).be.null(); + done(); + }); + }); + }); + it('rejects deleting samples of other users for write users', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000h00000000004', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000004', + auth: {basic: 'user'}, + httpStatus: 403 + }); + }); + it('returns 404 for an unknown id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/000000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + httpStatus: 401 + }); + }); + }); + describe('POST /sample/new', () => { it('returns the right sample', done => { TestHelper.request(server, done, { @@ -209,7 +590,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 400, req: {number: 'Rng172', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"color" is required'} }); }); it('rejects a missing sample number', done => { @@ -219,7 +600,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 400, req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"number" is required'} }); }); it('rejects a missing type', done => { @@ -229,7 +610,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 400, req: {number: 'Rng172', color: 'black', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"type" is required'} }); }); it('rejects a missing batch', done => { @@ -239,7 +620,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 400, req: {number: 'Rng172', color: 'black', type: 'granulate', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"batch" is required'} }); }); it('rejects a missing material id', done => { @@ -249,7 +630,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 400, req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"material_id" is required'} }); }); it('rejects an invalid material id', done => { @@ -259,7 +640,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 400, req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'} }); }); it('rejects an API key', done => { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index bbebaba..7415912 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -2,10 +2,12 @@ import express from 'express'; import SampleValidate from './validate/sample'; import NoteFieldValidate from './validate/note_field'; +import res400 from './validate/res400'; import SampleModel from '../models/sample' import MaterialModel from '../models/material'; import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; +import IdValidate from './validate/id'; @@ -20,66 +22,118 @@ router.get('/samples', (req, res, next) => { }) }); - -router.post('/sample/new', (req, res, next) => { +router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; - const {error, value: sample} = SampleValidate.input(req.body, 'new'); - if (error) { - return res.status(400).json({status: 'Invalid body format'}); - } + const {error, value: sample} = SampleValidate.input(req.body, 'change'); + if (error) return res400(error, res); - MaterialModel.findById(sample.material_id).lean().exec((err, data: any) => { // validate material_id + SampleModel.findById(req.params.id).lean().exec(async (err, sampleData: any) => { // check if id exists if (err) return next(err); - if (!data) { // could not find material_id - return res.status(400).json({status: 'Material not available'}); + if (!sampleData) { + return res.status(404).json({status: 'Not found'}); } - if (!data.numbers.find(e => e.color === sample.color)) { // color for material not specified - return res.status(400).json({status: 'Color not available for material'}); - } - SampleModel.findOne({number: sample.number}).lean().exec((err, data) => { // validate sample number - if (err) return next(err); - if (data) { // found entry with sample number - return res.status(400).json({status: 'Sample number already taken'}); - } + // only maintain and admin are allowed to edit other user's data + if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; - if (sample.notes.sample_references.length > 0) { // validate sample_references - let referencesCount = sample.notes.sample_references.length; - sample.notes.sample_references.forEach(reference => { - SampleModel.findById(reference.id).lean().exec((err, data) => { - if (err) return next(err); - if (!data) { - return res.status(400).json({status: 'Sample reference not available'}); - } - referencesCount --; - if (referencesCount <= 0) { - f(); - } + if (sample.hasOwnProperty('number') && sample.number !== sampleData.number) { + if (!await numberCheck(sample, res, next)) return; + } + if (sample.hasOwnProperty('material_id')) { + if (!await materialCheck(sample, res, next)) return; + } + else if (sample.hasOwnProperty('color')) { + if (!await materialCheck(sample, res, next, sampleData.material_id)) return; + } + + if (sample.hasOwnProperty('notes') && sampleData.note_id !== null) { // deal with old notes data + NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { + if (err) return console.error(err); + if (data.hasOwnProperty('custom_fields')) { // update note_fields + customFieldsChange(Object.keys(data.custom_fields), -1); + } + NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec(err => { // delete old notes + if (err) return console.error(err); + }) + }); + } + if (sample.hasOwnProperty('notes') && Object.keys(sample.notes).length > 0) { // save new notes + if (!await sampleRefCheck(sample, res, next)) return; + if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields + customFieldsChange(Object.keys(sample.notes.custom_fields), 1); + } + let data = await new NoteModel(sample.notes).save().catch(err => { return next(err)}); // save new notes + delete sample.notes; + sample.note_id = data._id; + } + SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).lean().exec((err, data) => { + if (err) return next(err); + res.json(SampleValidate.output(data)); + }); + + }); +}); + +router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + SampleModel.findById(req.params.id).lean().exec(async (err, sampleData: any) => { // check if id exists + if (err) return next(err); + if (!sampleData) { + return res.status(404).json({status: 'Not found'}); + } + // only maintain and admin are allowed to edit other user's data + if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; + + SampleModel.findByIdAndDelete(req.params.id).lean().exec(err => { // delete sample + if (err) return next(err); + if (sampleData.note_id !== null) { + NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec((err, data: any) => { // delete notes + if (err) return next(err); + console.log(data); + if (data.hasOwnProperty('custom_fields')) { // update note_fields + customFieldsChange(Object.keys(data.custom_fields), -1); + } + res.json({status: 'OK'}); + NoteModel.updateMany({'sample_references.id': req.params.id}, {$unset: {'sample_references.$': null}}).lean().exec(err => { // remove sample_references + if (err) console.error(err); + NoteModel.collection.updateMany({sample_references: null}, {$pull: {sample_references: null}}, err => { // only works with native MongoDB driver somehow + if (err) console.error(err); + }); }); }); } else { - f(); - } - - if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { - customFieldsAdd(Object.keys(sample.notes.custom_fields)); - } - - function f() { // to resolve async - new NoteModel(sample.notes).save((err, data) => { - if (err) return next(err); - delete sample.notes; - sample.note_id = data._id; - sample.user_id = req.authDetails.id; - new SampleModel(sample).save((err, data) => { - if (err) return next(err); - res.json(SampleValidate.output(data.toObject())); - }); - }); + res.json({status: 'OK'}); } }); - }) + }); +}); + +router.post('/sample/new', async (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const {error, value: sample} = SampleValidate.input(req.body, 'new'); + if (error) return res400(error, res); + + if (!await numberCheck(sample, res, next)) return; + if (!await materialCheck(sample, res, next)) return; + if (!await sampleRefCheck(sample, res, next)) return; + + if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields + customFieldsChange(Object.keys(sample.notes.custom_fields), 1); + } + + new NoteModel(sample.notes).save((err, data) => { + if (err) return next(err); + delete sample.notes; + sample.note_id = data._id; + sample.user_id = req.authDetails.id; + new SampleModel(sample).save((err, data) => { + if (err) return next(err); + res.json(SampleValidate.output(data.toObject())); + }); + }); }); router.get('/sample/notes/fields', (req, res, next) => { @@ -95,15 +149,69 @@ router.get('/sample/notes/fields', (req, res, next) => { module.exports = router; -function customFieldsAdd (fields) { +async function numberCheck (sample, res, next) { // validate number, returns false if invalid + const sampleData = await SampleModel.findOne({number: sample.number}).lean().exec().catch(err => { return next(err)}); + if (sampleData) { // found entry with sample number + res.status(400).json({status: 'Sample number already taken'}); + return false + } + return true; +} + +async function materialCheck (sample, res, next, id = sample.material_id) { // validate material_id and color, returns false if invalid + const materialData = await MaterialModel.findById(id).lean().exec().catch(err => {next(err);}) as any; + if (materialData instanceof Error) { + return false; + } + if (!materialData) { // could not find material_id + res.status(400).json({status: 'Material not available'}); + return false; + } + if (sample.hasOwnProperty('color') && !materialData.numbers.find(e => e.color === sample.color)) { // color for material not specified + res.status(400).json({status: 'Color not available for material'}); + return false; + } + return true; +} + +function sampleRefCheck (sample, res, next) { // validate sample_references, resolves false for invalid reference + return new Promise(resolve => { + if (sample.notes.sample_references.length > 0) { // there are sample_references + let referencesCount = sample.notes.sample_references.length; + sample.notes.sample_references.forEach(reference => { + SampleModel.findById(reference.id).lean().exec((err, data) => { + if (err) {next(err); resolve(false)} + if (!data) { + res.status(400).json({status: 'Sample reference not available'}); + return resolve(false); + } + referencesCount --; + if (referencesCount <= 0) { + resolve(true); + } + }); + }); + } + else { + resolve(true); + } + }); +} + +function customFieldsChange (fields, amount) { fields.forEach(field => { - NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: 1}}).lean().exec((err, data) => { // check if field exists + NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: amount}}, {new: true}).lean().exec((err, data: any) => { // check if field exists if (err) return console.error(err); if (!data) { // new field new NoteFieldModel({name: field, qty: 1}).save(err => { if (err) return console.error(err); }) } + else if (data.qty <= 0) { + NoteFieldModel.findOneAndDelete({name: field}).lean().exec(err => { + if (err) return console.error(err); + }); + } }); }); } \ No newline at end of file diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index 68b3d4a..6a4c7af 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -182,14 +182,54 @@ describe('/template', () => { }); }); }); - it('rejects an incomplete template for a new name', done => { + it('rejects a missing name for a new name', done => { TestHelper.request(server, done, { method: 'put', url: '/template/treatment/heat%20aging', auth: {basic: 'admin'}, httpStatus: 400, - req: {parameters: [{name: 'time'}]}, - res: {status: 'Invalid body format'} + req: {parameters: [{name: 'time', range: {min: 1}}]}, + res: {status: 'Invalid body format', details: '"name" is required'} + }); + }); + it('rejects missing parameters for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging'}, + res: {status: 'Invalid body format', details: '"parameters" is required'} + }); + }); + it('rejects a missing parameter name for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{range: {min: 1}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].name" is required'} + }); + }); + it('rejects a missing parameter range for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{name: 'time'}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} + }); + }); + it('rejects a an invalid parameter range property for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{name: 'time', range: {xx: 1}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} }); }); it('rejects already existing names', done => { @@ -209,7 +249,7 @@ describe('/template', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {parameters: [{name: 'time'}], xx: 33}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"name" is required'} }); }); it('rejects an API key', done => { @@ -466,14 +506,54 @@ describe('/template', () => { }); }); }); - it('rejects an incomplete template for a new name', done => { + it('rejects a missing name for a new name', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/vz', + url: '/template/measurement/spectrum2', auth: {basic: 'admin'}, httpStatus: 400, - req: {parameters: [{name: 'vz'}]}, - res: {status: 'Invalid body format'} + req: {parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, + res: {status: 'Invalid body format', details: '"name" is required'} + }); + }); + it('rejects missing parameters for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum2', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum'}, + res: {status: 'Invalid body format', details: '"parameters" is required'} + }); + }); + it('rejects a missing parameter name for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum2', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{range: {min: 0, max: 1000}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].name" is required'} + }); + }); + it('rejects a missing parameter range for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum2', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{name: 'data point table'}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} + }); + }); + it('rejects a an invalid parameter range property for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum2', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {xx: 0}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} }); }); it('rejects already existing names', done => { @@ -493,7 +573,7 @@ describe('/template', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {parameters: [{name: 'dpt'}], xx: 33}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} }); }); it('rejects an API key', done => { diff --git a/src/routes/template.ts b/src/routes/template.ts index 1e859cd..afd686e 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -3,6 +3,7 @@ import express from 'express'; import TemplateValidate from './validate/template'; import TemplateTreatmentModel from '../models/treatment_template'; import TemplateMeasurementModel from '../models/measurement_template'; +import res400 from './validate/res400'; const router = express.Router(); @@ -41,10 +42,7 @@ router.put('/template/:collection(measurement|treatment)/:name', (req, res, next if (err) next (err); const templateState = data? 'change': 'new'; const {error, value: template} = TemplateValidate.input(req.body, templateState); - if (error) { - res.status(400).json({status: 'Invalid body format'}); - return; - } + if (error) return res400(error, res); if (template.hasOwnProperty('name') && template.name !== req.params.name) { collectionModel.find({name: template.name}).lean().exec((err, data) => { diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index b103ef7..a3a0ed9 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -224,7 +224,7 @@ describe('/user', () => { req: {level: 'read'} }).end((err, res) => { if (err) return done (err); - should(res.body).be.eql({status: 'Invalid body format'}); + should(res.body).be.eql({status: 'Invalid body format', details: '"level" is not allowed'}); UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { if (err) return done(err); should(data).have.lengthOf(1); @@ -267,7 +267,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', location: 44, device_name: 'Alpha II'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"location" must be a string'} }); }); it('rejects an invalid email address', done => { @@ -277,7 +277,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {email: 'john.doe'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"email" must be a valid email'} }); }); it('rejects an invalid password', done => { @@ -287,7 +287,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {pass: 'password'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$/'} }); }); it('rejects requests from non-admins for another user', done => { @@ -515,7 +515,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 44, device_name: 'Alpha II'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"location" must be a string'} }); }); it('rejects an invalid user level', done => { @@ -525,7 +525,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'xxx', location: 'Rng', device_name: 'Alpha II'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"level" must be one of [read, write, maintain, dev, admin]'} }); }); it('rejects an invalid email address', done => { @@ -535,7 +535,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {email: 'john.doe', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"email" must be a valid email'} }); }); it('rejects an invalid password', done => { @@ -545,7 +545,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'password', level: 'read', location: 'Rng', device_name: 'Alpha II'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$/'} }); }); it('rejects requests from non-admins', done => { diff --git a/src/routes/user.ts b/src/routes/user.ts index a0161f9..db78527 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -5,6 +5,7 @@ import bcrypt from 'bcryptjs'; import UserValidate from './validate/user'; import UserModel from '../models/user'; import mail from '../helpers/mail'; +import res400 from './validate/res400'; const router = express.Router(); @@ -46,10 +47,7 @@ router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi username = req.params.username; } const {error, value: user} = UserValidate.input(req.body, 'change' + (req.authDetails.level === 'admin'? 'admin' : '')); - if (error) { - res.status(400).json({status: 'Invalid body format'}); - return; - } + if (error) return res400(error, res); if (user.hasOwnProperty('pass')) { user.pass = bcrypt.hashSync(user.pass, 10); @@ -122,10 +120,7 @@ router.post('/user/new', (req, res, next) => { // validate input const {error, value: user} = UserValidate.input(req.body, 'new'); - if (error) { - res.status(400).json({status: 'Invalid body format'}); - return; - } + if (error) return res400(error, res); // check that user does not already exist UserModel.find({name: user.name}).lean().exec( (err, data:any) => { diff --git a/src/routes/validate/id.ts b/src/routes/validate/id.ts index 5409993..a9bb70a 100644 --- a/src/routes/validate/id.ts +++ b/src/routes/validate/id.ts @@ -1,7 +1,7 @@ -import joi from '@hapi/joi'; +import Joi from '@hapi/joi'; export default class IdValidate { - private static id = joi.string().pattern(new RegExp('[0-9a-f]{24}')).length(24); + private static id = Joi.string().pattern(new RegExp('[0-9a-f]{24}')).length(24); static get () { return this.id; diff --git a/src/routes/validate/material.ts b/src/routes/validate/material.ts index 54cd749..c8b6e91 100644 --- a/src/routes/validate/material.ts +++ b/src/routes/validate/material.ts @@ -31,9 +31,11 @@ export default class MaterialValidate { // validate input for material numbers: joi.array() .items(joi.object({ color: joi.string() - .max(128), + .max(128) + .required(), number: joi.number() .min(0) + .required() })) }; @@ -46,7 +48,7 @@ export default class MaterialValidate { // validate input for material mineral: this.material.mineral.required(), glass_fiber: this.material.glass_fiber.required(), carbon_fiber: this.material.carbon_fiber.required(), - numbers: this.material.numbers + numbers: this.material.numbers.required() }).validate(data); } else if (param === 'change') { diff --git a/src/routes/validate/note_field.ts b/src/routes/validate/note_field.ts index 4892f22..7d34d98 100644 --- a/src/routes/validate/note_field.ts +++ b/src/routes/validate/note_field.ts @@ -1,15 +1,15 @@ -import joi from '@hapi/joi'; +import Joi from '@hapi/joi'; export default class NoteFieldValidate { private static note_field = { - name: joi.string() + name: Joi.string() .max(128), - qty: joi.number() + qty: Joi.number() }; static output (data) { - const {value, error} = joi.object({ + const {value, error} = Joi.object({ name: this.note_field.name, qty: this.note_field.qty }).validate(data, {stripUnknown: true}); diff --git a/src/routes/validate/res400.ts b/src/routes/validate/res400.ts new file mode 100644 index 0000000..5e032f7 --- /dev/null +++ b/src/routes/validate/res400.ts @@ -0,0 +1,3 @@ +export default function res400 (error, res) { + res.status(400).json({status: 'Invalid body format', details: error.details[0].message}); +} \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index d94cede..aa28304 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -1,41 +1,41 @@ -import joi from '@hapi/joi'; +import Joi from '@hapi/joi'; import IdValidate from './id'; export default class SampleValidate { private static sample = { - number: joi.string() + number: Joi.string() .max(128), - color: joi.string() + color: Joi.string() .max(128), - type: joi.string() + type: Joi.string() .max(128), - batch: joi.string() + batch: Joi.string() .max(128) .allow(''), - notes: joi.object({ - comment: joi.string() + notes: Joi.object({ + comment: Joi.string() .max(512), - sample_references: joi.array() - .items(joi.object({ + sample_references: Joi.array() + .items(Joi.object({ id: IdValidate.get(), - relation: joi.string() + relation: Joi.string() .max(128) })), - custom_fields: joi.object() - .pattern(/.*/, joi.alternatives() + custom_fields: Joi.object() + .pattern(/.*/, Joi.alternatives() .try( - joi.string().max(128), - joi.number(), - joi.boolean(), - joi.date() + Joi.string().max(128), + Joi.number(), + Joi.boolean(), + Joi.date() ) ) }) @@ -43,7 +43,7 @@ export default class SampleValidate { static input (data, param) { // validate data, param: new(everything required)/change(available attributes are validated) if (param === 'new') { - return joi.object({ + return Joi.object({ number: this.sample.number.required(), color: this.sample.color.required(), type: this.sample.type.required(), @@ -53,7 +53,14 @@ export default class SampleValidate { }).validate(data); } else if (param === 'change') { - return{error: 'Not implemented!', value: {}}; + return Joi.object({ + number: this.sample.number, + color: this.sample.color, + type: this.sample.type, + batch: this.sample.batch, + material_id: IdValidate.get(), + notes: this.sample.notes, + }).validate(data); } else { return{error: 'No parameter specified!', value: {}}; @@ -62,7 +69,7 @@ export default class SampleValidate { static output (data) { data = IdValidate.stringify(data); - const {value, error} = joi.object({ + const {value, error} = Joi.object({ _id: IdValidate.get(), number: this.sample.number, color: this.sample.color, diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index 150bf64..024d1a9 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -1,32 +1,32 @@ -import joi from '@hapi/joi'; +import Joi from '@hapi/joi'; import globals from '../../globals'; import IdValidate from './id'; export default class UserValidate { // validate input for user private static user = { - name: joi.string() + name: Joi.string() .alphanum() .lowercase() .max(128), - email: joi.string() + email: Joi.string() .email({minDomainSegments: 2}) .lowercase() .max(128), - pass: joi.string() + pass: Joi.string() .pattern(new RegExp('^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$')) .max(128), - level: joi.string() + level: Joi.string() .valid(...globals.levels), - location: joi.string() + location: Joi.string() .alphanum() .max(128), - device_name: joi.string() + device_name: Joi.string() .allow('') .max(128), }; @@ -35,7 +35,7 @@ export default class UserValidate { // validate input for user static input (data, param) { if (param === 'new') { - return joi.object({ + return Joi.object({ name: this.user.name.required(), email: this.user.email.required(), pass: this.user.pass.required(), @@ -45,7 +45,7 @@ export default class UserValidate { // validate input for user }).validate(data); } else if (param === 'change') { - return joi.object({ + return Joi.object({ name: this.user.name, email: this.user.email, pass: this.user.pass, @@ -54,7 +54,7 @@ export default class UserValidate { // validate input for user }).validate(data); } else if (param === 'changeadmin') { - return joi.object({ + return Joi.object({ name: this.user.name, email: this.user.email, pass: this.user.pass, @@ -70,7 +70,7 @@ export default class UserValidate { // validate input for user static output (data) { // validate output from database for needed properties, strip everything else data = IdValidate.stringify(data); - const {value, error} = joi.object({ + const {value, error} = Joi.object({ _id: IdValidate.get(), name: this.user.name, email: this.user.email, diff --git a/src/test/db.json b/src/test/db.json index 2d8a7d0..24daaca 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -61,7 +61,7 @@ "_id": {"$oid":"500000000000000000000002"}, "comment": "", "sample_references": [{ - "id": "400000000000000000000004", + "id": {"$oid":"400000000000000000000004"}, "relation": "granulate to sample" }], "custom_fields": { @@ -73,11 +73,12 @@ "_id": {"$oid":"500000000000000000000003"}, "comment": "", "sample_references": [{ - "id": "400000000000000000000003", + "id": {"$oid":"400000000000000000000003"}, "relation": "part to sample" }], "custom_fields": { - "not allowed for new applications": true + "not allowed for new applications": true, + "another_field": "is there" }, "__v": 0 } @@ -88,6 +89,12 @@ "name": "not allowed for new applications", "qty": 2, "__v": 0 + }, + { + "_id": {"$oid":"600000000000000000000002"}, + "name": "another_field", + "qty": 1, + "__v": 0 } ], "materials": [ From 16a1cf5ba8255e7537eb1fdb20ee42951bea38af Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 8 May 2020 09:58:12 +0200 Subject: [PATCH 18/83] deleting a material is rejected if it is referenced by a sample --- api/material.yaml | 2 ++ api/sample.yaml | 2 +- package.json | 3 ++- src/routes/material.spec.ts | 16 ++++++++++++---- src/routes/material.ts | 21 +++++++++++++++------ src/routes/root.spec.ts | 2 +- src/routes/sample.spec.ts | 2 +- src/routes/sample.ts | 2 +- src/routes/template.spec.ts | 2 +- src/routes/user.spec.ts | 2 +- src/{helpers/test.ts => test/helper.ts} | 2 +- src/test/loadDev.ts | 12 ++++++++++++ 12 files changed, 50 insertions(+), 18 deletions(-) rename src/{helpers/test.ts => test/helper.ts} (98%) create mode 100644 src/test/loadDev.ts diff --git a/api/material.yaml b/api/material.yaml index a3b80da..5e8bc13 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -79,6 +79,8 @@ responses: 200: $ref: 'api.yaml#/components/responses/Ok' + 400: + $ref: 'api.yaml#/components/responses/400' 401: $ref: 'api.yaml#/components/responses/401' 403: diff --git a/api/sample.yaml b/api/sample.yaml index 8ba92af..e911d9c 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -123,7 +123,7 @@ /sample/notes/fields: get: - summary: TODO list all existing field names for custom notes fields + summary: list all existing field names for custom notes fields description: 'Auth: all, levels: read, write, maintain, dev, admin' tags: - /sample diff --git a/package.json b/package.json index d3f9e63..9a69ea2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "tsc": "tsc", "test": "mocha dist/**/**.spec.js", "start": "tsc && node dist/index.js || exit 1", - "dev": "nodemon -e ts,yaml --exec \"npm run start\"" + "dev": "nodemon -e ts,yaml --exec \"npm run start\"", + "loadDev": "node dist/test/loadDev.js" }, "keywords": [], "author": "", diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index aa9a484..dbc646b 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -1,6 +1,6 @@ import should from 'should/as-function'; import MaterialModel from '../models/material'; -import TestHelper from "../helpers/test"; +import TestHelper from "../test/helper"; describe('/material', () => { @@ -271,20 +271,28 @@ describe('/material', () => { it('deletes the material', done => { TestHelper.request(server, done, { method: 'delete', - url: '/material/100000000000000000000001', + url: '/material/100000000000000000000002', auth: {basic: 'janedoe'}, httpStatus: 200 }).end((err, res) => { if (err) return done(err); should(res.body).be.eql({status: 'OK'}); - MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { + MaterialModel.findById('100000000000000000000002').lean().exec((err, data) => { if (err) return done(err); should(data).be.null(); done(); }); }); }); - it('rejects deleting a material referenced by samples'); + it('rejects deleting a material referenced by samples', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Material still in use'} + }) + }); it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'delete', diff --git a/src/routes/material.ts b/src/routes/material.ts index 29362e2..292f02f 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -2,8 +2,10 @@ import express from 'express'; import MaterialValidate from './validate/material'; import MaterialModel from '../models/material' +import SampleModel from '../models/sample'; import IdValidate from './validate/id'; import res400 from './validate/res400'; +import mongoose from 'mongoose'; const router = express.Router(); @@ -69,14 +71,21 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; - MaterialModel.findByIdAndDelete(req.params.id).lean().exec((err, data) => { + // check if there are still samples referencing this material + SampleModel.find({'material_id': new mongoose.Types.ObjectId(req.params.id)}).lean().exec((err, data) => { if (err) return next(err); - if (data) { - res.json({status: 'OK'}) - } - else { - res.status(404).json({status: 'Not found'}); + if (data.length) { + return res.status(400).json({status: 'Material still in use'}); } + MaterialModel.findByIdAndDelete(req.params.id).lean().exec((err, data) => { + if (err) return next(err); + if (data) { + res.json({status: 'OK'}) + } + else { + res.status(404).json({status: 'Not found'}); + } + }); }); }); diff --git a/src/routes/root.spec.ts b/src/routes/root.spec.ts index 25be1ba..f8a803f 100644 --- a/src/routes/root.spec.ts +++ b/src/routes/root.spec.ts @@ -1,4 +1,4 @@ -import TestHelper from "../helpers/test"; +import TestHelper from "../test/helper"; describe('/', () => { diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 98017f0..aa01a39 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -2,7 +2,7 @@ import should from 'should/as-function'; import SampleModel from '../models/sample'; import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; -import TestHelper from "../helpers/test"; +import TestHelper from "../test/helper"; describe('/sample', () => { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 7415912..fe12ed0 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -10,7 +10,6 @@ import NoteFieldModel from '../models/note_field'; import IdValidate from './validate/id'; - const router = express.Router(); router.get('/samples', (req, res, next) => { @@ -129,6 +128,7 @@ router.post('/sample/new', async (req, res, next) => { delete sample.notes; sample.note_id = data._id; sample.user_id = req.authDetails.id; + console.log(sample); new SampleModel(sample).save((err, data) => { if (err) return next(err); res.json(SampleValidate.output(data.toObject())); diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index 6a4c7af..fa9361f 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -1,7 +1,7 @@ import should from 'should/as-function'; import TemplateTreatmentModel from '../models/treatment_template'; import TemplateMeasurementModel from '../models/measurement_template'; -import TestHelper from "../helpers/test"; +import TestHelper from "../test/helper"; describe('/template', () => { diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index a3a0ed9..e294cb2 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -1,6 +1,6 @@ import should from 'should/as-function'; import UserModel from '../models/user'; -import TestHelper from "../helpers/test"; +import TestHelper from "../test/helper"; describe('/user', () => { diff --git a/src/helpers/test.ts b/src/test/helper.ts similarity index 98% rename from src/helpers/test.ts rename to src/test/helper.ts index 6c2fa72..26cb5a5 100644 --- a/src/helpers/test.ts +++ b/src/test/helper.ts @@ -28,7 +28,7 @@ export default class TestHelper { server = require('../index'); db.drop(err => { // reset database if (err) return done(err); - db.loadJson(require('../test/db.json'), done); + db.loadJson(require('./db.json'), done); }); return server } diff --git a/src/test/loadDev.ts b/src/test/loadDev.ts new file mode 100644 index 0000000..690044d --- /dev/null +++ b/src/test/loadDev.ts @@ -0,0 +1,12 @@ +import db from '../db'; + +db.connect('dev', () => { + console.info('dropping data...'); + db.drop(() => { // reset database + console.info('loading data...'); + db.loadJson(require('./db.json'), () => { + console.info('done'); + process.exit(0); + }); + }); +}); From 852c035dfc032923502ce7761cb5c51c28bede6b Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 8 May 2020 14:41:31 +0200 Subject: [PATCH 19/83] POST /condition/new --- api/condition.yaml | 34 +++- api/sample.yaml | 4 +- api/schemas.yaml | 3 + src/index.ts | 1 + src/models/condition.ts | 12 ++ src/routes/condition.spec.ts | 258 ++++++++++++++++++++++++++++++ src/routes/condition.ts | 65 ++++++++ src/routes/sample.ts | 4 +- src/routes/template.spec.ts | 4 +- src/routes/validate/condition.ts | 57 +++++++ src/routes/validate/parameters.ts | 37 +++++ src/test/db.json | 25 ++- 12 files changed, 492 insertions(+), 12 deletions(-) create mode 100644 src/models/condition.ts create mode 100644 src/routes/condition.spec.ts create mode 100644 src/routes/condition.ts create mode 100644 src/routes/validate/condition.ts create mode 100644 src/routes/validate/parameters.ts diff --git a/api/condition.yaml b/api/condition.yaml index 5efa2ac..38bc56c 100644 --- a/api/condition.yaml +++ b/api/condition.yaml @@ -22,8 +22,8 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: TODO add/change condition - description: 'Auth: basic, levels: write, maintain, dev, admin' + summary: TODO change condition + description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to reference samples created by another user' tags: - /condition security: @@ -69,5 +69,35 @@ $ref: 'api.yaml#/components/responses/403' 404: $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/condition/new: + post: + summary: TODO add condition + description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to reference samples created by another user' + tags: + - /condition + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Condition' + responses: + 200: + description: condition details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Condition' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' 500: $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/api/sample.yaml b/api/sample.yaml index e911d9c..4d2817b 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -42,7 +42,7 @@ $ref: 'api.yaml#/components/responses/500' put: summary: change sample - description: 'Auth: basic, levels: write, maintain, dev, admin, only maintain and admin are allowed to edit samples created by another user' + description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to edit samples created by another user' tags: - /sample security: @@ -72,7 +72,7 @@ $ref: 'api.yaml#/components/responses/500' delete: summary: delete sample - description: 'Auth: basic, levels: write, maintain, dev, admin, only maintain and admin are allowed to edit samples created by another user' + description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to edit samples created by another user' tags: - /sample security: diff --git a/api/schemas.yaml b/api/schemas.yaml index a7aa0e2..84722a5 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -120,6 +120,9 @@ Condition: properties: sample_id: $ref: 'api.yaml#/components/schemas/Id' + number: + type: string + example: B1 parameters: type: object treatment_template: diff --git a/src/index.ts b/src/index.ts index 3a87996..bb8e047 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,7 @@ app.use('/', require('./routes/sample')); app.use('/', require('./routes/material')); app.use('/', require('./routes/template')); app.use('/', require('./routes/user')); +app.use('/', require('./routes/condition')); // static files app.use('/static', express.static('static')); diff --git a/src/models/condition.ts b/src/models/condition.ts new file mode 100644 index 0000000..1e24daf --- /dev/null +++ b/src/models/condition.ts @@ -0,0 +1,12 @@ +import mongoose from 'mongoose'; +import SampleModel from './sample'; +import TreatmentTemplateModel from './treatment_template'; + +const ConditionSchema = new mongoose.Schema({ + sample_id: {type: mongoose.Schema.Types.ObjectId, ref: SampleModel}, + number: String, + parameters: mongoose.Schema.Types.Mixed, + treatment_template: {type: mongoose.Schema.Types.ObjectId, ref: TreatmentTemplateModel} +}); + +export default mongoose.model('condition', ConditionSchema); \ No newline at end of file diff --git a/src/routes/condition.spec.ts b/src/routes/condition.spec.ts new file mode 100644 index 0000000..2f17028 --- /dev/null +++ b/src/routes/condition.spec.ts @@ -0,0 +1,258 @@ +import should from 'should/as-function'; +import ConditionModel from '../models/condition'; +import TestHelper from "../test/helper"; + + +describe('/condition', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + + describe('GET /condition/id', () => { + it('returns the right condition', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/condition/700000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', parameters: {material: 'copper', weeks: 3}, treatment_template: '200000000000000000000001'} + }); + }); + it('returns the right condition for an API key'); + it('rejects an invalid id'); + it('rejects an unknown id'); + it('rejects unauthorized requests'); + }); + + describe('POST /condition/new', () => { + it('returns the right condition', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('sample_id', '400000000000000000000002'); + should(res.body).have.property('number', 'B2'); + should(res.body).have.property('treatment_template', '200000000000000000000001'); + should(res.body).have.property('parameters'); + should(res.body.parameters).have.property('material', 'hot air'); + should(res.body.parameters).have.property('weeks', 10); + done(); + }); + }); + it('stores the condition', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }).end((err, res) => { + if (err) return done(err); + ConditionModel.findById(res.body._id).lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', '__v'); + should(data).have.property('_id'); + should(data.sample_id.toString()).be.eql('400000000000000000000002'); + should(data).have.property('number', 'B2'); + should(data.treatment_template.toString()).be.eql('200000000000000000000001'); + should(data).have.property('parameters'); + should(data.parameters).have.property('material', 'hot air'); + should(data.parameters).have.property('weeks', 10); + done(); + }); + }); + }); + it('rejects an invalid sample id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '4000000000h0000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"sample_id" with value "4000000000h0000000000002" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects a missing sample id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '000000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Sample id not available'} + }); + }); + it('rejects an invalid treatment_template id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000h00000000001'}, + res: {status: 'Invalid body format', details: '"treatment_template" with value "200000000000h00000000001" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects a sample treatment_template which does not exist', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '000000000000000000000001'}, + res: {status: 'Treatment template not available'} + }); + }); + it('rejects a condition number already in use for this sample', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000001', number: 'B1', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Condition number already taken'} + }); + }); + it('rejects not specified parameters', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10, xx: 12}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"xx" is not allowed'} + }); + }); + it('rejects missing parameters', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air'}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"weeks" is required'} + }); + }); + it('rejects a parameter not in the value range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'xxx', weeks: 10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"material" must be one of [copper, hot air]'} + }); + }); + it('rejects a parameter below minimum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: -10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"weeks" must be larger than or equal to 1'} + }); + }); + it('rejects a parameter above maximum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 11}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"weeks" must be less than or equal to 10'} + }); + }); + it('rejects a missing sample id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"sample_id" is required'} + }); + }); + it('rejects a missing treatment_template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}}, + res: {status: 'Invalid body format', details: '"treatment_template" is required'} + }); + }); + it('rejects a missing number', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"number" is required'} + }); + }); + it('rejects adding a condition to the sample of an other user for a write user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {sample_id: '400000000000000000000003', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }); + }); + it('accepts adding a condition to the sample of an other user for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('sample_id', '400000000000000000000002'); + should(res.body).have.property('number', 'B2'); + should(res.body).have.property('treatment_template', '200000000000000000000001'); + should(res.body).have.property('parameters'); + should(res.body.parameters).have.property('material', 'hot air'); + should(res.body.parameters).have.property('weeks', 10); + done(); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'user'}, + httpStatus: 403, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + httpStatus: 401, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/condition.ts b/src/routes/condition.ts new file mode 100644 index 0000000..0cf113d --- /dev/null +++ b/src/routes/condition.ts @@ -0,0 +1,65 @@ +import express from 'express'; +import mongoose from 'mongoose'; + +import ConditionValidate from './validate/condition'; +import ParametersValidate from './validate/parameters'; +import res400 from './validate/res400'; +import SampleModel from '../models/sample'; +import ConditionModel from '../models/condition'; +import TreatmentTemplateModel from '../models/treatment_template'; + + +const router = express.Router(); + +router.post('/condition/new', async (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const {error, value: condition} = ConditionValidate.input(req.body, 'new'); + if (error) return res400(error, res); + + if (!await sampleIdCheck(condition, req, res, next)) return; + if (!await numberCheck(condition, res, next)) return; + if (!await treatmentCheck(condition, res, next)) return; + + new ConditionModel(condition).save((err, data) => { + if (err) return next(err); + res.json(ConditionValidate.output(data.toObject())); + }); +}) + + +module.exports = router; + + +async function sampleIdCheck (condition, req, res, next) { // validate sample_id, returns false if invalid + const sampleData = await SampleModel.findById(condition.sample_id).lean().exec().catch(err => {next(err); return false;}) as any; + if (!sampleData) { // sample_id not found + res.status(400).json({status: 'Sample id not available'}); + return false + } + + if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return false; // sample does not belong to user + return true; +} + +async function numberCheck (condition, res, next) { // validate number, returns false if invalid + const data = await ConditionModel.find({sample_id: new mongoose.Types.ObjectId(condition.sample_id), number: condition.number}).lean().exec().catch(err => {next(err); return false;}) as any; + if (data.length) { + res.status(400).json({status: 'Condition number already taken'}); + return false; + } + return true; +} + +async function treatmentCheck (condition, res, next) { + const treatmentData = await TreatmentTemplateModel.findById(condition.treatment_template).lean().exec().catch(err => {next(err); return false;}) as any; + if (!treatmentData) { // sample_id not found + res.status(400).json({status: 'Treatment template not available'}); + return false + } + + // validate parameters + const {error, value: ignore} = ParametersValidate.input(condition.parameters, treatmentData.parameters); + if (error) {res400(error, res); return false;} + return true; +} \ No newline at end of file diff --git a/src/routes/sample.ts b/src/routes/sample.ts index fe12ed0..85619fa 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -150,7 +150,7 @@ module.exports = router; async function numberCheck (sample, res, next) { // validate number, returns false if invalid - const sampleData = await SampleModel.findOne({number: sample.number}).lean().exec().catch(err => { return next(err)}); + const sampleData = await SampleModel.findOne({number: sample.number}).lean().exec().catch(err => {next(err); return false;}); if (sampleData) { // found entry with sample number res.status(400).json({status: 'Sample number already taken'}); return false @@ -159,7 +159,7 @@ async function numberCheck (sample, res, next) { // validate number, returns fa } async function materialCheck (sample, res, next, id = sample.material_id) { // validate material_id and color, returns false if invalid - const materialData = await MaterialModel.findById(id).lean().exec().catch(err => {next(err);}) as any; + const materialData = await MaterialModel.findById(id).lean().exec().catch(err => {next(err); return false;}) as any; if (materialData instanceof Error) { return false; } diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index fa9361f..eea3ea4 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -121,7 +121,7 @@ describe('/template', () => { TemplateTreatmentModel.find({name: 'heat aging'}).lean().exec((err, data:any) => { if (err) return done(err); should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'parameters'); + should(data[0]).have.only.keys('_id', 'name', 'parameters', '__v'); should(data[0]).have.property('name', 'heat aging'); should(data[0]).have.property('parameters').have.lengthOf(1); should(data[0].parameters[0]).have.property('name', 'time'); @@ -443,7 +443,7 @@ describe('/template', () => { TemplateMeasurementModel.find({name: 'IR spectrum'}).lean().exec((err, data:any) => { if (err) return done(err); should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'parameters'); + should(data[0]).have.only.keys('_id', 'name', 'parameters', '__v'); should(data[0]).have.property('name', 'IR spectrum'); should(data[0]).have.property('parameters').have.lengthOf(1); should(data[0].parameters[0]).have.property('name', 'data point table'); diff --git a/src/routes/validate/condition.ts b/src/routes/validate/condition.ts new file mode 100644 index 0000000..4c4673f --- /dev/null +++ b/src/routes/validate/condition.ts @@ -0,0 +1,57 @@ +import Joi from '@hapi/joi'; + +import IdValidate from './id'; + +export default class ConditionValidate { + private static condition = { + sample_id: IdValidate.get(), + + number: Joi.string() + .max(128), + + parameters: Joi.object() + .pattern(/.*/, Joi.alternatives() + .try( + Joi.string().max(128), + Joi.number(), + Joi.boolean() + ) + ), + + treatment_template: IdValidate.get() + } + + static input (data, param) { + if (param === 'new') { + return Joi.object({ + sample_id: this.condition.sample_id.required(), + number: this.condition.number.required(), + parameters: this.condition.parameters.required(), + treatment_template: this.condition.treatment_template.required() + }).validate(data); + } + else if (param === 'change') { + return Joi.object({ + sample_id: this.condition.sample_id, + number: this.condition.number, + parameters: this.condition.parameters, + treatment_template: this.condition.treatment_template + }).validate(data); + } + else { + return{error: 'No parameter specified!', value: {}}; + } + } + + static output (data) { + data = IdValidate.stringify(data); + const {value, error} = Joi.object({ + _id: IdValidate.get(), + sample_id: this.condition.sample_id, + number: this.condition.number, + parameters: this.condition.parameters, + treatment_template: this.condition.treatment_template + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/routes/validate/parameters.ts b/src/routes/validate/parameters.ts new file mode 100644 index 0000000..d14c6e2 --- /dev/null +++ b/src/routes/validate/parameters.ts @@ -0,0 +1,37 @@ +import Joi from '@hapi/joi'; + +export default class ParametersValidate { + static input (data, parameters) { + let joiObject = {}; + parameters.forEach(parameter => { + if (parameter.range.hasOwnProperty('values')) { + joiObject[parameter.name] = Joi.alternatives() + .try(Joi.string(), Joi.number(), Joi.boolean()) + .valid(...parameter.range.values) + .required(); + } + else if (parameter.range.hasOwnProperty('min') && parameter.range.hasOwnProperty('max')) { + joiObject[parameter.name] = Joi.number() + .min(parameter.range.min) + .max(parameter.range.max) + .required(); + } + else if (parameter.range.hasOwnProperty('min')) { + joiObject[parameter.name] = Joi.number() + .min(parameter.range.min) + .required(); + } + else if (parameter.range.hasOwnProperty('max')) { + joiObject[parameter.name] = Joi.number() + .max(parameter.range.max) + .required(); + } + else { + joiObject[parameter.name] = Joi.alternatives() + .try(Joi.string(), Joi.number(), Joi.boolean()) + .required(); + } + }); + return Joi.object(joiObject).validate(data); + } +} \ No newline at end of file diff --git a/src/test/db.json b/src/test/db.json index 24daaca..95ff0fc 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -183,6 +183,19 @@ "__v": 0 } ], + "conditions": [ + { + "_id": {"$oid":"700000000000000000000001"}, + "sample_id": {"$oid":"400000000000000000000001"}, + "number": "B1", + "parameters": { + "material": "copper", + "weeks": 3 + }, + "treatment_template": {"$oid":"200000000000000000000001"}, + "__v": 0 + } + ], "treatment_templates": [ { "_id": {"$oid":"200000000000000000000001"}, @@ -204,7 +217,8 @@ "max": 10 } } - ] + ], + "__v": 0 }, { "_id": {"$oid":"200000000000000000000002"}, @@ -214,7 +228,8 @@ "name": "material", "range": {} } - ] + ], + "__v": 0 } ], "measurement_templates": [ @@ -226,7 +241,8 @@ "name": "dpt", "range": {} } - ] + ], + "__v": 0 }, { "_id": {"$oid":"300000000000000000000002"}, @@ -246,7 +262,8 @@ "max": 0.5 } } - ] + ], + "__v": 0 } ], "users": [ From 0ec9b44462cace9a8c59e3747fb57b5b2b7ce87b Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 8 May 2020 15:12:36 +0200 Subject: [PATCH 20/83] DELETE and GET methods for condition --- .idea/dataSources.local.xml | 15 + .../46f112fc-d60d-4217-873f-f5ffea06180c.xml | 584 ++++++++++++++++++ api/condition.yaml | 4 +- src/routes/condition.spec.ts | 117 +++- src/routes/condition.ts | 31 + src/routes/material.ts | 2 +- src/test/db.json | 22 + 7 files changed, 767 insertions(+), 8 deletions(-) create mode 100644 .idea/dataSources.local.xml create mode 100644 .idea/dataSources/46f112fc-d60d-4217-873f-f5ffea06180c.xml diff --git a/.idea/dataSources.local.xml b/.idea/dataSources.local.xml new file mode 100644 index 0000000..ca0dc03 --- /dev/null +++ b/.idea/dataSources.local.xml @@ -0,0 +1,15 @@ + + + + + + + master_key + + + + + + + + \ No newline at end of file diff --git a/.idea/dataSources/46f112fc-d60d-4217-873f-f5ffea06180c.xml b/.idea/dataSources/46f112fc-d60d-4217-873f-f5ffea06180c.xml new file mode 100644 index 0000000..1619391 --- /dev/null +++ b/.idea/dataSources/46f112fc-d60d-4217-873f-f5ffea06180c.xml @@ -0,0 +1,584 @@ + + + + + 4.2.5 + + + + + + + + 1 + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + String(0)|12s + + + 4 + String(0)|12s + + + 5 + String(0)|12s + + + 6 + String(0)|12s + + + 7 + list(0)|4999545s + + + 8 + ObjectId(0)|12s + + + 9 + String(0)|12s + + + 10 + Double(0)|8s + + + 11 + String(0)|12s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + list(0)|4999545s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + String(0)|12s + + + 4 + ObjectId(0)|12s + + + 5 + ObjectId(0)|12s + + + 6 + String(0)|12s + + + 7 + String(0)|12s + + + 8 + ObjectId(0)|12s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + String(0)|12s + + + 4 + String(0)|12s + + + 5 + String(0)|12s + + + 6 + String(0)|12s + + + 7 + String(0)|12s + + + 8 + String(0)|12s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + Integer|4s + + + 3 + Integer|4s + + + 4 + String(0)|12s + + + 5 + Integer|4s + + + 6 + String(0)|12s + + + 7 + list(0)|4999545s + + + 8 + String(0)|12s + + + 9 + Double(0)|8s + + + 10 + String(0)|12s + + + ObjectId(0)|12s + + + 1 + String(0)|12s + + + 2 + list(0)|4999545s + + + 3 + String(0)|12s + + + 4 + map(0)|4999544s + + + 5 + Double(0)|8s + + + 6 + Integer|4s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + Integer|4s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + map(0)|4999544s + + + 4 + String(0)|12s + + + 5 + Boolean|12s + + + 6 + list(0)|4999545s + + + 7 + String(0)|12s + + + 8 + String(0)|12s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + String(0)|12s + + + 4 + ObjectId(0)|12s + + + 5 + String(0)|12s + + + 6 + String(0)|12s + + + 7 + String(0)|12s + + + 8 + ObjectId(0)|12s + + + 9 + Boolean|12s + + + ObjectId(0)|12s + + + 1 + String(0)|12s + + + 2 + list(0)|4999545s + + + 3 + String(0)|12s + + + 4 + map(0)|4999544s + + + 5 + Integer|4s + + + 6 + Integer|4s + + + 7 + array(0)|2003s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + String(0)|12s + + + 3 + String(0)|12s + + + 4 + String(0)|12s + + + 5 + String(0)|12s + + + 6 + String(0)|12s + + + 7 + String(0)|12s + + + 8 + String(0)|12s + + + String(0)|12s + + + 1 + map(0)|4999544s + + + 2 + String(0)|12s + + + 3 + Integer|4s + + + 4 + map(0)|4999544s + + + 5 + String(0)|12s + + + 6 + String(0)|12s + + + 7 + String(0)|12s + + + 8 + String(0)|12s + + + 9 + String(0)|12s + + + 10 + String(0)|12s + + + 11 + String(0)|12s + + + 12 + String(0)|12s + + + 13 + String(0)|12s + + + 14 + Boolean|12s + + + 15 + String(0)|12s + + + 16 + String(0)|12s + + + 17 + Integer|4s + + + 18 + list(0)|4999545s + + + 19 + map(0)|4999544s + + + 20 + String(0)|12s + + + 21 + array(0)|2003s + + + 22 + String(0)|12s + + + 23 + String(0)|12s + + + 24 + String(0)|12s + + + 25 + array(0)|2003s + + + 26 + map(0)|4999544s + + + 27 + String(0)|12s + + + 28 + map(0)|4999544s + + + 29 + String(0)|12s + + + 30 + Integer|4s + + + 31 + Boolean|12s + + + 32 + map(0)|4999544s + + + 33 + String(0)|12s + + + 34 + map(0)|4999544s + + + 35 + Boolean|12s + + + 36 + map(0)|4999544s + + + 37 + String(0)|12s + + + 38 + Boolean|12s + + + 39 + String(0)|12s + + + 40 + String(0)|12s + + + 41 + Long(0)|12s + + + 42 + Date(0)|91s + + + 43 + String(0)|12s + + + ObjectId(0)|12s + + + 1 + Integer|4s + + + 2 + Integer|4s + + + ObjectId(0)|12s + + + 1 + String(0)|12s + + + \ No newline at end of file diff --git a/api/condition.yaml b/api/condition.yaml index 38bc56c..32f410b 100644 --- a/api/condition.yaml +++ b/api/condition.yaml @@ -2,7 +2,7 @@ parameters: - $ref: 'api.yaml#/components/parameters/Id' get: - summary: TODO condition by id + summary: condition by id description: 'Auth: all, levels: read, write, maintain, dev, admin' tags: - /condition @@ -74,7 +74,7 @@ /condition/new: post: - summary: TODO add condition + summary: add condition description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to reference samples created by another user' tags: - /condition diff --git a/src/routes/condition.spec.ts b/src/routes/condition.spec.ts index 2f17028..60e7d78 100644 --- a/src/routes/condition.spec.ts +++ b/src/routes/condition.spec.ts @@ -9,7 +9,7 @@ describe('/condition', () => { beforeEach(done => server = TestHelper.beforeEach(server, done)); afterEach(done => TestHelper.afterEach(server, done)); - describe('GET /condition/id', () => { + describe('GET /condition/{id}', () => { it('returns the right condition', done => { TestHelper.request(server, done, { method: 'get', @@ -19,10 +19,117 @@ describe('/condition', () => { res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', parameters: {material: 'copper', weeks: 3}, treatment_template: '200000000000000000000001'} }); }); - it('returns the right condition for an API key'); - it('rejects an invalid id'); - it('rejects an unknown id'); - it('rejects unauthorized requests'); + it('returns the right condition for an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/condition/700000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 200, + res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', parameters: {material: 'copper', weeks: 3}, treatment_template: '200000000000000000000001'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/condition/70000000000t000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/condition/000000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/condition/700000000000000000000001', + httpStatus: 401 + }); + }); + }); + + describe('DELETE /condition/{id}', () => { + it('deletes the condition', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/condition/700000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + ConditionModel.findById('700000000000000000000002').lean().exec((err, data) => { + if (err) return done(err); + should(data).be.null(); + done(); + }); + }); + }); + it('rejects a deleting a condition referenced by measurements'); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/condition/70000000000w000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/condition/700000000000000000000002', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/condition/700000000000000000000002', + auth: {basic: 'user'}, + httpStatus: 403 + }); + }); + it('rejects a write user deleting a condition belonging to a sample of another user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/condition/700000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('accepts an maintain/admin user deleting a condition belonging to a sample of another user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/condition/700000000000000000000002', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + done(); + }); + }); + it('returns 404 for an unknown id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/condition/00000000000w000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/condition/700000000000000000000002', + httpStatus: 401 + }); + }); }); describe('POST /condition/new', () => { diff --git a/src/routes/condition.ts b/src/routes/condition.ts index 0cf113d..f5fa085 100644 --- a/src/routes/condition.ts +++ b/src/routes/condition.ts @@ -7,10 +7,41 @@ import res400 from './validate/res400'; import SampleModel from '../models/sample'; import ConditionModel from '../models/condition'; import TreatmentTemplateModel from '../models/treatment_template'; +import IdValidate from './validate/id'; const router = express.Router(); +router.get('/condition/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + ConditionModel.findById(req.params.id).lean().exec((err, data) => { + if (err) return next(err); + if (data) { + res.json(ConditionValidate.output(data)); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); +}); + +router.delete('/condition/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + ConditionModel.findById(req.params.id).lean().exec(async (err, data: any) => { + if (err) return next(err); + if (!data) { + res.status(404).json({status: 'Not found'}); + } + if (!await sampleIdCheck(data, req, res, next)) return; + ConditionModel.findByIdAndDelete(req.params.id).lean().exec(async err => { + if (err) return next(err); + res.json({status: 'OK'}); + }); + }); +}); + router.post('/condition/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; diff --git a/src/routes/material.ts b/src/routes/material.ts index 292f02f..c6f0c60 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -80,7 +80,7 @@ router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { MaterialModel.findByIdAndDelete(req.params.id).lean().exec((err, data) => { if (err) return next(err); if (data) { - res.json({status: 'OK'}) + res.json({status: 'OK'}); } else { res.status(404).json({status: 'Not found'}); diff --git a/src/test/db.json b/src/test/db.json index 95ff0fc..2545a71 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -194,6 +194,28 @@ }, "treatment_template": {"$oid":"200000000000000000000001"}, "__v": 0 + }, + { + "_id": {"$oid":"700000000000000000000002"}, + "sample_id": {"$oid":"400000000000000000000002"}, + "number": "B1", + "parameters": { + "material": "copper", + "weeks": 3 + }, + "treatment_template": {"$oid":"200000000000000000000001"}, + "__v": 0 + }, + { + "_id": {"$oid":"700000000000000000000003"}, + "sample_id": {"$oid":"400000000000000000000004"}, + "number": "B1", + "parameters": { + "material": "copper", + "weeks": 3 + }, + "treatment_template": {"$oid":"200000000000000000000001"}, + "__v": 0 } ], "treatment_templates": [ From c9be3f4eb7c7f9d1fc5d3cc84915d0e37eeab18f Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 11 May 2020 13:05:54 +0200 Subject: [PATCH 21/83] PUT method for condition --- api/api.yaml | 2 +- api/condition.yaml | 13 ++- api/measurement.yaml | 32 +++++- src/routes/condition.spec.ts | 157 +++++++++++++++++++++++++++++- src/routes/condition.ts | 36 ++++++- src/routes/material.spec.ts | 2 +- src/routes/sample.spec.ts | 2 +- src/routes/template.spec.ts | 2 +- src/routes/validate/condition.ts | 4 +- src/routes/validate/parameters.ts | 20 ++-- src/test/db.json | 11 +++ 11 files changed, 253 insertions(+), 28 deletions(-) diff --git a/api/api.yaml b/api/api.yaml index 44756ae..ed387a3 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -27,7 +27,7 @@ info:
  • no whitespace
  • at least 8 characters
  • - +# TODO: Link to new documentation page servers: diff --git a/api/condition.yaml b/api/condition.yaml index 32f410b..696aa4d 100644 --- a/api/condition.yaml +++ b/api/condition.yaml @@ -22,7 +22,7 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: TODO change condition + summary: change condition description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to reference samples created by another user' tags: - /condition @@ -33,7 +33,14 @@ content: application/json: schema: - $ref: 'api.yaml#/components/schemas/Condition' + allOf: + - $ref: 'api.yaml#/components/schemas/_Id' + properties: + number: + type: string + example: B1 + parameters: + type: object responses: 200: description: condition details @@ -52,7 +59,7 @@ 500: $ref: 'api.yaml#/components/responses/500' delete: - summary: TODO delete condition + summary: delete condition description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /condition diff --git a/api/measurement.yaml b/api/measurement.yaml index 0f86047..84e6237 100644 --- a/api/measurement.yaml +++ b/api/measurement.yaml @@ -22,7 +22,7 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: TODO add/change measurement + summary: TODO change measurement description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /measurement @@ -69,5 +69,35 @@ $ref: 'api.yaml#/components/responses/403' 404: $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/measurement/new: + post: + summary: TODO add measurement + description: 'Auth: basic, levels: write, maintain, dev, admin' + tags: + - /measurement + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Measurement' + responses: + 200: + description: measurement details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Measurement' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' 500: $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/src/routes/condition.spec.ts b/src/routes/condition.spec.ts index 60e7d78..5884b2e 100644 --- a/src/routes/condition.spec.ts +++ b/src/routes/condition.spec.ts @@ -53,6 +53,157 @@ describe('/condition', () => { }); }); + describe('PUT /condition{id}', () => { + it('returns the right condition', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {}, + res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}} + }); + }); + it('keeps unchanged properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {parameters: {material: 'copper', weeks: 3}}, + res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}} + }); + }); + it('changes the given properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {parameters: {material: 'hot air', weeks: 10}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'hot air', weeks: 10}}); + ConditionModel.findById('700000000000000000000001').lean().exec((err, data: any) => { + if (err) return done(err); + should(data.sample_id.toString()).be.eql('400000000000000000000001'); + should(data).have.property('number', 'B1'); + should(data.treatment_template.toString()).be.eql('200000000000000000000001'); + should(data).have.property('parameters'); + should(data.parameters).have.property('material', 'hot air'); + should(data.parameters).have.property('weeks', 10); + done(); + }); + }); + }); + it('allows changing only one parameter', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {parameters: {weeks: 8}}, + res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 8}} + }); + }); + it('rejects not specified parameters', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {parameters: {xx: 13}}, + res: {status: 'Invalid body format', details: '"xx" is not allowed'} + }); + }); + it('rejects a parameter not in the value range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {parameters: {material: 'xxx'}}, + res: {status: 'Invalid body format', details: '"material" must be one of [copper, hot air]'} + }); + }); + it('rejects a parameter below minimum range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {parameters: {weeks: -10}}, + res: {status: 'Invalid body format', details: '"weeks" must be larger than or equal to 1'} + }); + }); + it('rejects a parameter above maximum range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {parameters: {weeks: 11}}, + res: {status: 'Invalid body format', details: '"weeks" must be less than or equal to 10'} + }); + }); + it('rejects a new treatment_template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {treatment_template: '200000000000000000000002'}, + res: {status: 'Invalid body format', details: '"treatment_template" is not allowed'} + }); + }); + it('rejects editing a condition for a write user who did not create this condition', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {parameters: {weeks: 8}} + }); + }); + it('accepts editing a condition of another user for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: {material: 'hot air', weeks: 10}}, + res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'hot air', weeks: 10}} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {parameters: {material: 'hot air', weeks: 10}} + }); + }); + it('rejects requests form a read user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {basic: 'user'}, + httpStatus: 403, + req: {parameters: {material: 'hot air', weeks: 10}} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + httpStatus: 401, + req: {parameters: {material: 'hot air', weeks: 10}} + }); + }); + }); // TODO: how to deal with template changes? Template versioning? + // TODO: rewrite delete methods -> set status for every database collection + describe('DELETE /condition/{id}', () => { it('deletes the condition', done => { TestHelper.request(server, done, { @@ -132,7 +283,7 @@ describe('/condition', () => { }); }); - describe('POST /condition/new', () => { + describe('POST /condition/new', () => { // TODO: sample number generation it('returns the right condition', done => { TestHelper.request(server, done, { method: 'post', @@ -186,7 +337,7 @@ describe('/condition', () => { res: {status: 'Invalid body format', details: '"sample_id" with value "4000000000h0000000000002" fails to match the required pattern: /[0-9a-f]{24}/'} }); }); - it('rejects a missing sample id', done => { + it('rejects a sample id not available', done => { TestHelper.request(server, done, { method: 'post', url: '/condition/new', @@ -206,7 +357,7 @@ describe('/condition', () => { res: {status: 'Invalid body format', details: '"treatment_template" with value "200000000000h00000000001" fails to match the required pattern: /[0-9a-f]{24}/'} }); }); - it('rejects a sample treatment_template which does not exist', done => { + it('rejects a treatment_template which does not exist', done => { TestHelper.request(server, done, { method: 'post', url: '/condition/new', diff --git a/src/routes/condition.ts b/src/routes/condition.ts index f5fa085..687ea2a 100644 --- a/src/routes/condition.ts +++ b/src/routes/condition.ts @@ -26,6 +26,35 @@ router.get('/condition/' + IdValidate.parameter(), (req, res, next) => { }); }); +router.put('/condition/' + IdValidate.parameter(), async (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const {error, value: condition} = ConditionValidate.input(req.body, 'change'); + console.log(error); + if (error) return res400(error, res); + + const data = await ConditionModel.findById(req.params.id).lean().exec().catch(err => {next(err);}) as any; + if (data instanceof Error) { + return; + } + if (!data) { + res.status(404).json({status: 'Not found'}); + } + condition.treatment_template = data.treatment_template; + condition.sample_id = data.sample_id; + if (!await sampleIdCheck(condition, req, res, next)) return; + if (condition.parameters) { + condition.parameters = Object.assign(data.parameters, condition.parameters); + } + if (!await treatmentCheck(condition, 'change', res, next)) return; + + console.log(condition); + ConditionModel.findByIdAndUpdate(req.params.id, condition, {new: true}).lean().exec((err, data) => { + if (err) return next(err); + res.json(ConditionValidate.output(data)); + }); +}); + router.delete('/condition/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; @@ -50,7 +79,7 @@ router.post('/condition/new', async (req, res, next) => { if (!await sampleIdCheck(condition, req, res, next)) return; if (!await numberCheck(condition, res, next)) return; - if (!await treatmentCheck(condition, res, next)) return; + if (!await treatmentCheck(condition, 'new', res, next)) return; new ConditionModel(condition).save((err, data) => { if (err) return next(err); @@ -82,7 +111,7 @@ async function numberCheck (condition, res, next) { // validate number, returns return true; } -async function treatmentCheck (condition, res, next) { +async function treatmentCheck (condition, param, res, next) { const treatmentData = await TreatmentTemplateModel.findById(condition.treatment_template).lean().exec().catch(err => {next(err); return false;}) as any; if (!treatmentData) { // sample_id not found res.status(400).json({status: 'Treatment template not available'}); @@ -90,7 +119,8 @@ async function treatmentCheck (condition, res, next) { } // validate parameters - const {error, value: ignore} = ParametersValidate.input(condition.parameters, treatmentData.parameters); + const {error, value: ignore} = ParametersValidate.input(condition.parameters, treatmentData.parameters, param); + console.log(error); if (error) {res400(error, res); return false;} return true; } \ No newline at end of file diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index dbc646b..1e7e7ff 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -153,7 +153,7 @@ describe('/material', () => { should(res.body).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}]}); MaterialModel.findById('100000000000000000000001').lean().exec((err, data:any) => { if (err) return done(err); - data._id = data._id.toString({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}]}); + data._id = data._id.toString(); data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: '0', glass_fiber: '35', carbon_fiber: '0', numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}], __v: 0} ); diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index aa01a39..28acff9 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -3,7 +3,7 @@ import SampleModel from '../models/sample'; import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; import TestHelper from "../test/helper"; - +// TODO: generate sample number describe('/sample', () => { let server; diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index eea3ea4..d3f973a 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -3,7 +3,7 @@ import TemplateTreatmentModel from '../models/treatment_template'; import TemplateMeasurementModel from '../models/measurement_template'; import TestHelper from "../test/helper"; - +// TODO: remove DELETE methods, only updates possible describe('/template', () => { let server; before(done => TestHelper.before(done)); diff --git a/src/routes/validate/condition.ts b/src/routes/validate/condition.ts index 4c4673f..10d90f5 100644 --- a/src/routes/validate/condition.ts +++ b/src/routes/validate/condition.ts @@ -32,10 +32,8 @@ export default class ConditionValidate { } else if (param === 'change') { return Joi.object({ - sample_id: this.condition.sample_id, number: this.condition.number, - parameters: this.condition.parameters, - treatment_template: this.condition.treatment_template + parameters: this.condition.parameters }).validate(data); } else { diff --git a/src/routes/validate/parameters.ts b/src/routes/validate/parameters.ts index d14c6e2..ab1149b 100644 --- a/src/routes/validate/parameters.ts +++ b/src/routes/validate/parameters.ts @@ -1,35 +1,33 @@ import Joi from '@hapi/joi'; export default class ParametersValidate { - static input (data, parameters) { + static input (data, parameters, param) { // data to validate, parameters from template, param: 'new', 'change' let joiObject = {}; parameters.forEach(parameter => { if (parameter.range.hasOwnProperty('values')) { joiObject[parameter.name] = Joi.alternatives() .try(Joi.string(), Joi.number(), Joi.boolean()) - .valid(...parameter.range.values) - .required(); + .valid(...parameter.range.values); } else if (parameter.range.hasOwnProperty('min') && parameter.range.hasOwnProperty('max')) { joiObject[parameter.name] = Joi.number() .min(parameter.range.min) - .max(parameter.range.max) - .required(); + .max(parameter.range.max); } else if (parameter.range.hasOwnProperty('min')) { joiObject[parameter.name] = Joi.number() - .min(parameter.range.min) - .required(); + .min(parameter.range.min); } else if (parameter.range.hasOwnProperty('max')) { joiObject[parameter.name] = Joi.number() - .max(parameter.range.max) - .required(); + .max(parameter.range.max); } else { joiObject[parameter.name] = Joi.alternatives() - .try(Joi.string(), Joi.number(), Joi.boolean()) - .required(); + .try(Joi.string(), Joi.number(), Joi.boolean()); + } + if (param === 'new') { + joiObject[parameter.name] = joiObject[parameter.name].required() } }); return Joi.object(joiObject).validate(data); diff --git a/src/test/db.json b/src/test/db.json index 2545a71..64079ef 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -216,6 +216,17 @@ }, "treatment_template": {"$oid":"200000000000000000000001"}, "__v": 0 + }, + { + "_id": {"$oid":"700000000000000000000004"}, + "sample_id": {"$oid":"400000000000000000000001"}, + "number": "B3", + "parameters": { + "material": "hot air", + "weeks": 5 + }, + "treatment_template": {"$oid":"200000000000000000000001"}, + "__v": 0 } ], "treatment_templates": [ From a516062822de3d3452061e97e3bc5656101af66c Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Tue, 12 May 2020 12:15:36 +0200 Subject: [PATCH 22/83] GET and POST method for measurement --- src/index.ts | 1 + src/models/measurement.ts | 12 ++ src/routes/condition.spec.ts | 2 +- src/routes/condition.ts | 8 +- src/routes/material.spec.ts | 2 +- src/routes/measurement.spec.ts | 260 +++++++++++++++++++++++++++++ src/routes/measurement.ts | 68 ++++++++ src/routes/sample.spec.ts | 2 + src/routes/validate/condition.ts | 17 +- src/routes/validate/measurement.ts | 46 +++++ src/test/db.json | 33 +++- 11 files changed, 434 insertions(+), 17 deletions(-) create mode 100644 src/models/measurement.ts create mode 100644 src/routes/measurement.spec.ts create mode 100644 src/routes/measurement.ts create mode 100644 src/routes/validate/measurement.ts diff --git a/src/index.ts b/src/index.ts index bb8e047..4c0beca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ app.use('/', require('./routes/material')); app.use('/', require('./routes/template')); app.use('/', require('./routes/user')); app.use('/', require('./routes/condition')); +app.use('/', require('./routes/measurement')); // static files app.use('/static', express.static('static')); diff --git a/src/models/measurement.ts b/src/models/measurement.ts new file mode 100644 index 0000000..401103b --- /dev/null +++ b/src/models/measurement.ts @@ -0,0 +1,12 @@ +import mongoose from 'mongoose'; +import ConditionModel from './condition'; +import MeasurementTemplateModel from './measurement_template'; + +const MeasurementSchema = new mongoose.Schema({ + condition_id: {type: mongoose.Schema.Types.ObjectId, ref: ConditionModel}, + values: mongoose.Schema.Types.Mixed, + status: Number, + measurement_template: {type: mongoose.Schema.Types.ObjectId, ref: MeasurementTemplateModel} +}); + +export default mongoose.model('measurement', MeasurementSchema); \ No newline at end of file diff --git a/src/routes/condition.spec.ts b/src/routes/condition.spec.ts index 5884b2e..bc7f0a2 100644 --- a/src/routes/condition.spec.ts +++ b/src/routes/condition.spec.ts @@ -1,7 +1,7 @@ import should from 'should/as-function'; import ConditionModel from '../models/condition'; import TestHelper from "../test/helper"; - +// TODO: status describe('/condition', () => { let server; diff --git a/src/routes/condition.ts b/src/routes/condition.ts index 687ea2a..517e623 100644 --- a/src/routes/condition.ts +++ b/src/routes/condition.ts @@ -49,7 +49,7 @@ router.put('/condition/' + IdValidate.parameter(), async (req, res, next) => { if (!await treatmentCheck(condition, 'change', res, next)) return; console.log(condition); - ConditionModel.findByIdAndUpdate(req.params.id, condition, {new: true}).lean().exec((err, data) => { + await ConditionModel.findByIdAndUpdate(req.params.id, condition, {new: true}).lean().exec((err, data) => { if (err) return next(err); res.json(ConditionValidate.output(data)); }); @@ -64,7 +64,7 @@ router.delete('/condition/' + IdValidate.parameter(), (req, res, next) => { res.status(404).json({status: 'Not found'}); } if (!await sampleIdCheck(data, req, res, next)) return; - ConditionModel.findByIdAndDelete(req.params.id).lean().exec(async err => { + await ConditionModel.findByIdAndDelete(req.params.id).lean().exec(async err => { if (err) return next(err); res.json({status: 'OK'}); }); @@ -81,7 +81,7 @@ router.post('/condition/new', async (req, res, next) => { if (!await numberCheck(condition, res, next)) return; if (!await treatmentCheck(condition, 'new', res, next)) return; - new ConditionModel(condition).save((err, data) => { + await new ConditionModel(condition).save((err, data) => { if (err) return next(err); res.json(ConditionValidate.output(data.toObject())); }); @@ -113,7 +113,7 @@ async function numberCheck (condition, res, next) { // validate number, returns async function treatmentCheck (condition, param, res, next) { const treatmentData = await TreatmentTemplateModel.findById(condition.treatment_template).lean().exec().catch(err => {next(err); return false;}) as any; - if (!treatmentData) { // sample_id not found + if (!treatmentData) { // template not found res.status(400).json({status: 'Treatment template not available'}); return false } diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 1e7e7ff..59bdd4a 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -1,7 +1,7 @@ import should from 'should/as-function'; import MaterialModel from '../models/material'; import TestHelper from "../test/helper"; - +// TODO: status describe('/material', () => { let server; diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts new file mode 100644 index 0000000..c1ed0fa --- /dev/null +++ b/src/routes/measurement.spec.ts @@ -0,0 +1,260 @@ +import should from 'should/as-function'; +import MeasurementModel from '../models/measurement'; +import TestHelper from "../test/helper"; + +describe('/measurement', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + + describe('GET /mesurement/{id}', () => { + it('returns the right measurement', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} + }); + }); + it('returns the measurement for an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/800000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 200, + res: {_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/8000000000h0000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/000000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/800000000000000000000001', + httpStatus: 401 + }); + }); + }); + + describe('POST /measurement/new', () => { + it('returns the right measurement', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'condition_id', 'values', 'measurement_template'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('condition_id', '700000000000000000000001'); + should(res.body).have.property('measurement_template', '300000000000000000000002'); + should(res.body).have.property('values'); + should(res.body.values).have.property('weight %', 0.8); + should(res.body.values).have.property('standard deviation', 0.1); + done(); + }); + }); + it('stores the measurement', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }).end((err, res) => { + if (err) return done(err); + MeasurementModel.findById(res.body._id).lean().exec((err, data: any) => { + should(data).have.only.keys('_id', 'condition_id', 'values', 'measurement_template', '__v'); + should(data.condition_id.toString()).be.eql('700000000000000000000001'); + should(data.measurement_template.toString()).be.eql('300000000000000000000002'); + should(data).have.property('values'); + should(data.values).have.property('weight %', 0.8); + should(data.values).have.property('standard deviation', 0.1); + done(); + }); + }); + }); + it('rejects an invalid condition id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000h00000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"condition_id" with value "700000000000h00000000001" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects a condition id not available', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '000000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Condition id not available'} + }); + }); + it('rejects an invalid measurement_template id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '30000000000h000000000002'}, + res: {status: 'Invalid body format', details: '"measurement_template" with value "30000000000h000000000002" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects a measurement_template not available', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '000000000000000000000002'}, + res: {status: 'Measurement template not available'} + }); + }); + it('rejects not specified values', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1, xx: 44}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"xx" is not allowed'} + }); + }); + it('rejects missing values', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"standard deviation" is required'} + }); + }); + it('rejects a value not in the value range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {val1: 4}, measurement_template: '300000000000000000000003'}, + res: {status: 'Invalid body format', details: '"val1" must be one of [1, 2, 3]'} + }); + }); + it('rejects a value below minimum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': -1, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"weight %" must be larger than or equal to 0'} + }); + }); + it('rejects a value above maximum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 2}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"standard deviation" must be less than or equal to 0.5'} + }); + }); + it('rejects a missing condition id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"condition_id" is required'} + }); + }); + it('rejects a missing measurement_template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}}, + res: {status: 'Invalid body format', details: '"measurement_template" is required'} + }); + }); + it('rejects adding a measurement to the sample of another user for a write user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {condition_id: '700000000000000000000003', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }); + }); + it('accepts adding a measurement to the sample of another user for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'condition_id', 'values', 'measurement_template'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('condition_id', '700000000000000000000001'); + should(res.body).have.property('measurement_template', '300000000000000000000002'); + should(res.body).have.property('values'); + should(res.body.values).have.property('weight %', 0.8); + should(res.body.values).have.property('standard deviation', 0.1); + done(); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'user'}, + httpStatus: 403, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + httpStatus: 401, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts new file mode 100644 index 0000000..8dda6cd --- /dev/null +++ b/src/routes/measurement.ts @@ -0,0 +1,68 @@ +import express from 'express'; + +import MeasurementModel from '../models/measurement'; +import ConditionModel from '../models/condition'; +import MeasurementTemplateModel from '../models/measurement_template'; +import MeasurementValidate from './validate/measurement'; +import IdValidate from './validate/id'; +import res400 from './validate/res400'; +import ParametersValidate from './validate/parameters'; + + +const router = express.Router(); + +router.get('/measurement/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + MeasurementModel.findById(req.params.id).lean().exec((err, data) => { + if (err) return next(err); + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + + res.json(MeasurementValidate.output(data)); + }); +}); + +router.post('/measurement/new', async (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const {error, value: measurement} = MeasurementValidate.input(req.body, 'new'); + if (error) return res400(error, res); + + if (!await conditionIdCheck(measurement, req, res, next)) return; + if (!await templateCheck(measurement, 'new', res, next)) return; + + await new MeasurementModel(measurement).save((err, data) => { + if (err) return next(err); + res.json(MeasurementValidate.output(data.toObject())); + }); +}); + + +module.exports = router; + + +async function conditionIdCheck (measurement, req, res, next) { // validate condition_id, returns false if invalid + const sampleData = await ConditionModel.findById(measurement.condition_id).populate('sample_id').lean().exec().catch(err => {next(err); return false;}) as any; + if (!sampleData) { // sample_id not found + res.status(400).json({status: 'Condition id not available'}); + return false + } + if (sampleData.sample_id.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return false; // sample does not belong to user + return true; +} + +async function templateCheck (measurement, param, res, next) { // validate measurement_template and values + const templateData = await MeasurementTemplateModel.findById(measurement.measurement_template).lean().exec().catch(err => {next(err); return false;}) as any; + if (!templateData) { // template not found + res.status(400).json({status: 'Measurement template not available'}); + return false + } + + // validate values + const {error, value: ignore} = ParametersValidate.input(measurement.values, templateData.parameters, param); + console.log(error); + if (error) {res400(error, res); return false;} + return true; +} \ No newline at end of file diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 28acff9..d74703d 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -4,6 +4,8 @@ import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; import TestHelper from "../test/helper"; // TODO: generate sample number +// TODO: think again which parameters are required at POST +// TODO: status describe('/sample', () => { let server; diff --git a/src/routes/validate/condition.ts b/src/routes/validate/condition.ts index 10d90f5..f130076 100644 --- a/src/routes/validate/condition.ts +++ b/src/routes/validate/condition.ts @@ -4,8 +4,6 @@ import IdValidate from './id'; export default class ConditionValidate { private static condition = { - sample_id: IdValidate.get(), - number: Joi.string() .max(128), @@ -14,20 +12,19 @@ export default class ConditionValidate { .try( Joi.string().max(128), Joi.number(), - Joi.boolean() + Joi.boolean(), + Joi.array() ) - ), - - treatment_template: IdValidate.get() + ) } static input (data, param) { if (param === 'new') { return Joi.object({ - sample_id: this.condition.sample_id.required(), + sample_id: IdValidate.get().required(), number: this.condition.number.required(), parameters: this.condition.parameters.required(), - treatment_template: this.condition.treatment_template.required() + treatment_template: IdValidate.get().required() }).validate(data); } else if (param === 'change') { @@ -45,10 +42,10 @@ export default class ConditionValidate { data = IdValidate.stringify(data); const {value, error} = Joi.object({ _id: IdValidate.get(), - sample_id: this.condition.sample_id, + sample_id: IdValidate.get(), number: this.condition.number, parameters: this.condition.parameters, - treatment_template: this.condition.treatment_template + treatment_template: IdValidate.get() }).validate(data, {stripUnknown: true}); return error !== undefined? null : value; } diff --git a/src/routes/validate/measurement.ts b/src/routes/validate/measurement.ts new file mode 100644 index 0000000..0efaaea --- /dev/null +++ b/src/routes/validate/measurement.ts @@ -0,0 +1,46 @@ +import Joi from '@hapi/joi'; + +import IdValidate from './id'; + +export default class MeasurementValidate { + private static measurement = { + values: Joi.object() + .pattern(/.*/, Joi.alternatives() + .try( + Joi.string().max(128), + Joi.number(), + Joi.boolean(), + Joi.array() + ) + ) + }; + + static input (data, param) { + if (param === 'new') { + return Joi.object({ + condition_id: IdValidate.get().required(), + values: this.measurement.values.required(), + measurement_template: IdValidate.get().required() + }).validate(data); + } + else if (param === 'change') { + return Joi.object({ + values: this.measurement.values + }).validate(data); + } + else { + return{error: 'No parameter specified!', value: {}}; + } + } + + static output (data) { + data = IdValidate.stringify(data); + const {value, error} = Joi.object({ + _id: IdValidate.get(), + condition_id: IdValidate.get(), + values: this.measurement.values, + measurement_template: IdValidate.get() + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/test/db.json b/src/test/db.json index 64079ef..06ac3d6 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -229,6 +229,22 @@ "__v": 0 } ], + "measurements": [ + { + "_id": {"$oid":"800000000000000000000001"}, + "condition_id": {"$oid":"700000000000000000000001"}, + "values": { + "dpt": [ + [3997.12558,98.00555], + [3995.08519,98.03253], + [3993.04480,98.02657] + ] + }, + "status": 10, + "measurement_template": {"$oid":"300000000000000000000001"}, + "__v": 0 + } + ], "treatment_templates": [ { "_id": {"$oid":"200000000000000000000001"}, @@ -272,7 +288,9 @@ "parameters": [ { "name": "dpt", - "range": {} + "range": { + "type": "array" + } } ], "__v": 0 @@ -297,6 +315,19 @@ } ], "__v": 0 + }, + { + "_id": {"$oid":"300000000000000000000003"}, + "name": "mt 3", + "parameters": [ + { + "name": "val1", + "range": { + "values": [1,2,3] + } + } + ], + "__v": 0 } ], "users": [ From 5c9aa8debd5fec9abbb994377bd16cd8788f8b31 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Tue, 12 May 2020 12:26:26 +0200 Subject: [PATCH 23/83] added status to POST method --- src/routes/measurement.spec.ts | 3 ++- src/routes/measurement.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index c1ed0fa..79d0606 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -82,9 +82,10 @@ describe('/measurement', () => { }).end((err, res) => { if (err) return done(err); MeasurementModel.findById(res.body._id).lean().exec((err, data: any) => { - should(data).have.only.keys('_id', 'condition_id', 'values', 'measurement_template', '__v'); + should(data).have.only.keys('_id', 'condition_id', 'values', 'measurement_template', 'status', '__v'); should(data.condition_id.toString()).be.eql('700000000000000000000001'); should(data.measurement_template.toString()).be.eql('300000000000000000000002'); + should(data).have.property('status', 0); should(data).have.property('values'); should(data.values).have.property('weight %', 0.8); should(data.values).have.property('standard deviation', 0.1); diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index 8dda6cd..9f7bf41 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -33,6 +33,7 @@ router.post('/measurement/new', async (req, res, next) => { if (!await conditionIdCheck(measurement, req, res, next)) return; if (!await templateCheck(measurement, 'new', res, next)) return; + measurement.status = await new MeasurementModel(measurement).save((err, data) => { if (err) return next(err); res.json(MeasurementValidate.output(data.toObject())); From 57152c1ab2c5c5604f8472af1d19a38dc7c36aaf Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Tue, 12 May 2020 14:05:47 +0200 Subject: [PATCH 24/83] implemented feature --- api/measurement.yaml | 1 + src/api.ts | 42 +++++++++++++++++++++++++++++++++++++++ src/index.ts | 12 ++--------- static/styles/swagger.css | 17 ++++++++++++++++ 4 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 src/api.ts diff --git a/api/measurement.yaml b/api/measurement.yaml index 84e6237..53fe973 100644 --- a/api/measurement.yaml +++ b/api/measurement.yaml @@ -76,6 +76,7 @@ post: summary: TODO add measurement description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: 'Adds status: 0 automatically' tags: - /measurement security: diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..77a60ca --- /dev/null +++ b/src/api.ts @@ -0,0 +1,42 @@ +import swagger from 'swagger-ui-express'; +import jsonRefParser, {JSONSchema} from '@apidevtools/json-schema-ref-parser'; + + +// modifies the normal swagger-ui-express package +// usage: app.use('/api', api.serve(), api.setup()); +// the paths property can be split using allOf +// further route documentation can be included in the x-doc property + +export default class api { + static serve () { + return swagger.serve; + } + + static setup () { + let apiDoc: JSONSchema = {}; + jsonRefParser.bundle('api/api.yaml', (err, doc) => { // parse yaml + if(err) throw err; + apiDoc = doc; + apiDoc.paths = apiDoc.paths.allOf.reduce((s, e) => Object.assign(s, e)); // bundle routes + apiDoc = this.resolveXDoc(apiDoc); + swagger.setup(apiDoc); + }); + return swagger.setup(apiDoc, {customCssUrl: '/static/styles/swagger.css'}) + } + + private static resolveXDoc (doc) { // resolve x-doc properties recursively + Object.keys(doc).forEach(key => { + if (doc[key] !== null && doc[key].hasOwnProperty('x-doc')) { + doc[key].description += this.addHtml(doc[key]['x-doc']); + } + else if (typeof doc[key] === 'object' && doc[key] !== null) { // go deeper into recursion + doc[key] = this.resolveXDoc(doc[key]); + } + }); + return doc; + } + + private static addHtml (text) { // add docs HTML + return '
    docs' + text + '
    '; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index bb8e047..0c67dac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,8 @@ import express from 'express'; import bodyParser from 'body-parser'; -import swagger from 'swagger-ui-express'; -import jsonRefParser, {JSONSchema} from '@apidevtools/json-schema-ref-parser'; import contentFilter from 'content-filter'; import mongoSanitize from 'mongo-sanitize'; +import api from './api'; import db from './db'; @@ -55,14 +54,7 @@ app.use('/', require('./routes/condition')); app.use('/static', express.static('static')); // Swagger UI -let apiDoc: JSONSchema = {}; -jsonRefParser.bundle('api/api.yaml', (err, doc) => { - if(err) throw err; - apiDoc = doc; - apiDoc.paths = apiDoc.paths.allOf.reduce((s, e) => Object.assign(s, e)); - swagger.setup(apiDoc, {defaultModelsExpandDepth: -1, customCss: '.swagger-ui .topbar { display: none }'}); -}); -app.use('/api', swagger.serve, swagger.setup(apiDoc, {customCssUrl: '/static/styles/swagger.css'})); +app.use('/api', api.serve(), api.setup()); app.use((req, res) => { // 404 error handling res.status(404).json({status: 'Not found'}); diff --git a/static/styles/swagger.css b/static/styles/swagger.css index 33bebe1..9760ed4 100644 --- a/static/styles/swagger.css +++ b/static/styles/swagger.css @@ -52,6 +52,23 @@ body:after { font-family: "Bosch Sans", sans-serif; } +/*custom docs*/ +.docs { + position: relative; + font-size: 14px; +} + +.docs > summary { + position: absolute; + right: 0; + top: -25px; + cursor: pointer; +} + +.docs-open:hover { + text-decoration: underline; +} + /*Remove topbar*/ .swagger-ui .topbar { display: none From ff36b49cc53ffe74c86ee43702e784e7f20d2626 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Tue, 12 May 2020 17:15:36 +0200 Subject: [PATCH 25/83] PUT method --- api/api.yaml | 7 ++ api/measurement.yaml | 5 +- package-lock.json | 9 +- package.json | 2 + src/routes/condition.spec.ts | 2 +- src/routes/condition.ts | 1 - src/routes/material.ts | 3 +- src/routes/measurement.spec.ts | 172 ++++++++++++++++++++++++++++++ src/routes/measurement.ts | 33 +++++- src/routes/sample.ts | 5 +- src/routes/template.ts | 3 +- src/routes/user.ts | 3 +- src/routes/validate/parameters.ts | 14 ++- src/test/db.json | 21 ++++ 14 files changed, 267 insertions(+), 13 deletions(-) diff --git a/api/api.yaml b/api/api.yaml index ed387a3..0c17f4d 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -27,6 +27,13 @@ info:
  • no whitespace
  • at least 8 characters
  • + x-doc: | + status: +
      +
    • -10: deleted
    • +
    • 0: newly added/changed
    • +
    • 10: validated
    • +
    # TODO: Link to new documentation page diff --git a/api/measurement.yaml b/api/measurement.yaml index 53fe973..7d1c0c4 100644 --- a/api/measurement.yaml +++ b/api/measurement.yaml @@ -24,6 +24,7 @@ put: summary: TODO change measurement description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: status is reset to 0 on any changes tags: - /measurement security: @@ -33,7 +34,9 @@ content: application/json: schema: - $ref: 'api.yaml#/components/schemas/Measurement' + properties: + values: + type: object responses: 200: description: measurement details diff --git a/package-lock.json b/package-lock.json index 839b669..4629b37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -137,6 +137,12 @@ "@types/range-parser": "*" } }, + "@types/lodash": { + "version": "4.14.150", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.150.tgz", + "integrity": "sha512-kMNLM5JBcasgYscD9x/Gvr6lTAv2NVgsKtet/hm93qMyf/D1pt+7jeEZklKJKxMVmXjxbRVQQGfqDSfipYCO6w==", + "dev": true + }, "@types/mime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", @@ -1345,8 +1351,7 @@ "lodash": { "version": "4.17.15", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", - "dev": true + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, "log-symbols": { "version": "3.0.0", diff --git a/package.json b/package.json index 9a69ea2..e58c0a0 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "content-filter": "^1.1.2", "express": "^4.17.1", "json-schema": "^0.2.5", + "lodash": "^4.17.15", "mongo-sanitize": "^1.1.0", "mongoose": "^5.8.7", "nodemon": "^2.0.3", @@ -40,6 +41,7 @@ "typescript": "^3.7.4" }, "devDependencies": { + "@types/lodash": "^4.14.150", "mocha": "^7.1.2", "should": "^13.2.3", "supertest": "^4.0.2" diff --git a/src/routes/condition.spec.ts b/src/routes/condition.spec.ts index bc7f0a2..ec71ac3 100644 --- a/src/routes/condition.spec.ts +++ b/src/routes/condition.spec.ts @@ -184,7 +184,7 @@ describe('/condition', () => { req: {parameters: {material: 'hot air', weeks: 10}} }); }); - it('rejects requests form a read user', done => { + it('rejects requests from a read user', done => { TestHelper.request(server, done, { method: 'put', url: '/condition/700000000000000000000001', diff --git a/src/routes/condition.ts b/src/routes/condition.ts index 517e623..a5639e6 100644 --- a/src/routes/condition.ts +++ b/src/routes/condition.ts @@ -48,7 +48,6 @@ router.put('/condition/' + IdValidate.parameter(), async (req, res, next) => { } if (!await treatmentCheck(condition, 'change', res, next)) return; - console.log(condition); await ConditionModel.findByIdAndUpdate(req.params.id, condition, {new: true}).lean().exec((err, data) => { if (err) return next(err); res.json(ConditionValidate.output(data)); diff --git a/src/routes/material.ts b/src/routes/material.ts index c6f0c60..fdb0c47 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -1,4 +1,5 @@ import express from 'express'; +import _ from 'lodash'; import MaterialValidate from './validate/material'; import MaterialModel from '../models/material' @@ -15,7 +16,7 @@ router.get('/materials', (req, res, next) => { MaterialModel.find({}).lean().exec((err, data) => { if (err) return next(err); - res.json(data.map(e => MaterialValidate.output(e)).filter(e => e !== null)); // validate all and filter null values from validation errors + res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors }); }); diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index 79d0606..08b27ef 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -52,6 +52,177 @@ describe('/measurement', () => { }); }); + describe('PUT /measurement/{id}', () => { + it('returns the right measurement', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {}, + res: {_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} + }); + }); + it('keeps unchanged values', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'}); + MeasurementModel.findById('800000000000000000000001').lean().exec((err, data: any) => { + should(data).have.property('status', 10); + done(); + }); + }); + }); + it('changes the given values', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {values: {dpt: [[1,2],[3,4],[5,6]]}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[1,2],[3,4],[5,6]]}, measurement_template: '300000000000000000000001'}); + MeasurementModel.findById('800000000000000000000001').lean().exec((err, data: any) => { + should(data).have.only.keys('_id', 'condition_id', 'values', 'measurement_template', 'status', '__v'); + should(data.condition_id.toString()).be.eql('700000000000000000000001'); + should(data.measurement_template.toString()).be.eql('300000000000000000000001'); + should(data).have.property('status', 0); + should(data).have.property('values'); + should(data.values).have.property('dpt', [[1,2],[3,4],[5,6]]); + done(); + }); + }); + }); + it('allows changing only one value', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {values: {'weight %': 0.9}}, + res: {_id: '800000000000000000000002', condition_id: '700000000000000000000002', values: {'weight %': 0.9, 'standard deviation': 0.2}, measurement_template: '300000000000000000000002'} + }); + }); + it('rejects not specified values', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3, xx: 44}}, + res: {status: 'Invalid body format', details: '"xx" is not allowed'} + }); + }); + it('rejects a value not in the value range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {values: {val1: 4}}, + res: {status: 'Invalid body format', details: '"val1" must be one of [1, 2, 3]'} + }); + }); + it('rejects a value below minimum range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {values: {'weight %': -1, 'standard deviation': 0.3}}, + res: {status: 'Invalid body format', details: '"weight %" must be larger than or equal to 0'} + }); + }); + it('rejects a value above maximum range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {values: {'weight %': 0.9, 'standard deviation': 3}}, + res: {status: 'Invalid body format', details: '"standard deviation" must be less than or equal to 0.5'} + }); + }); + it('rejects a new measurement template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3}, measurement_template: '300000000000000000000001'}, + res: {status: 'Invalid body format', details: '"measurement_template" is not allowed'} + }); + }); + it('rejects editing a measurement for a write user who did not create this measurement', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {values: {val1: 2}} + }); + }); + it('accepts editing a measurement of another user for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3}}, + res: {_id: '800000000000000000000002', condition_id: '700000000000000000000002', values: {'weight %': 0.9, 'standard deviation': 0.3}, measurement_template: '300000000000000000000002'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000h00000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/000000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3}}, + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'user'}, + httpStatus: 403, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3}}, + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + httpStatus: 401, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3}}, + }); + }); + }); + describe('POST /measurement/new', () => { it('returns the right measurement', done => { TestHelper.request(server, done, { @@ -82,6 +253,7 @@ describe('/measurement', () => { }).end((err, res) => { if (err) return done(err); MeasurementModel.findById(res.body._id).lean().exec((err, data: any) => { + if (err) return done(err); should(data).have.only.keys('_id', 'condition_id', 'values', 'measurement_template', 'status', '__v'); should(data.condition_id.toString()).be.eql('700000000000000000000001'); should(data.measurement_template.toString()).be.eql('300000000000000000000002'); diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index 9f7bf41..a340a31 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -1,4 +1,5 @@ import express from 'express'; +import _ from 'lodash'; import MeasurementModel from '../models/measurement'; import ConditionModel from '../models/condition'; @@ -24,6 +25,36 @@ router.get('/measurement/' + IdValidate.parameter(), (req, res, next) => { }); }); +router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const {error, value: measurement} = MeasurementValidate.input(req.body, 'change'); + if (error) return res400(error, res); + + const data = await MeasurementModel.findById(req.params.id).lean().exec().catch(err => {next(err);}) as any; + if (data instanceof Error) { + return; + } + if (!data) { + res.status(404).json({status: 'Not found'}); + } + // add properties needed for conditionIdCheck + measurement.measurement_template = data.measurement_template; + measurement.condition_id = data.condition_id; + if (measurement.hasOwnProperty('values') && !_.isEqual(measurement.values, data.values)) { + measurement.status = 0; + } + if (!await conditionIdCheck(measurement, req, res, next)) return; + if (measurement.values) { + measurement.values = Object.assign(data.values, measurement.values); + } + if (!await templateCheck(measurement, 'change', res, next)) return; + await MeasurementModel.findByIdAndUpdate(req.params.id, measurement, {new: true}).lean().exec((err, data) => { + if (err) return next(err); + res.json(MeasurementValidate.output(data)); + }); +}); + router.post('/measurement/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; @@ -33,7 +64,7 @@ router.post('/measurement/new', async (req, res, next) => { if (!await conditionIdCheck(measurement, req, res, next)) return; if (!await templateCheck(measurement, 'new', res, next)) return; - measurement.status = + measurement.status = 0; await new MeasurementModel(measurement).save((err, data) => { if (err) return next(err); res.json(MeasurementValidate.output(data.toObject())); diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 85619fa..abc5747 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -1,4 +1,5 @@ import express from 'express'; +import _ from 'lodash'; import SampleValidate from './validate/sample'; import NoteFieldValidate from './validate/note_field'; @@ -17,7 +18,7 @@ router.get('/samples', (req, res, next) => { SampleModel.find({}).lean().exec((err, data) => { if (err) return next(err); - res.json(data.map(e => SampleValidate.output(e)).filter(e => e !== null)); // validate all and filter null values from validation errors + res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors }) }); @@ -141,7 +142,7 @@ router.get('/sample/notes/fields', (req, res, next) => { NoteFieldModel.find({}).lean().exec((err, data) => { if (err) return next(err); - res.json(data.map(e => NoteFieldValidate.output(e)).filter(e => e !== null)); // validate all and filter null values from validation errors + res.json(_.compact(data.map(e => NoteFieldValidate.output(e)))); // validate all and filter null values from validation errors }) }); diff --git a/src/routes/template.ts b/src/routes/template.ts index afd686e..2c0277c 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -1,4 +1,5 @@ import express from 'express'; +import _ from 'lodash'; import TemplateValidate from './validate/template'; import TemplateTreatmentModel from '../models/treatment_template'; @@ -14,7 +15,7 @@ router.get('/template/:collection(measurements|treatments)', (req, res, next) => (req.params.collection === 'treatments' ? TemplateTreatmentModel : TemplateMeasurementModel) .find({}).lean().exec((err, data) => { if (err) next (err); - res.json(data.map(e => TemplateValidate.output(e)).filter(e => e !== null)); // validate all and filter null values from validation errors + res.json(_.compact(data.map(e => TemplateValidate.output(e)))); // validate all and filter null values from validation errors }); }); diff --git a/src/routes/user.ts b/src/routes/user.ts index db78527..5a2485c 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -1,6 +1,7 @@ import express from 'express'; import mongoose from 'mongoose'; import bcrypt from 'bcryptjs'; +import _ from 'lodash'; import UserValidate from './validate/user'; import UserModel from '../models/user'; @@ -14,7 +15,7 @@ router.get('/users', (req, res) => { if (!req.auth(res, ['admin'], 'basic')) return; UserModel.find({}).lean().exec( (err, data:any) => { - res.json(data.map(e => UserValidate.output(e)).filter(e => e !== null)); // validate all and filter null values from validation errors + res.json(_.compact(data.map(e => UserValidate.output(e)))); // validate all and filter null values from validation errors }); }); diff --git a/src/routes/validate/parameters.ts b/src/routes/validate/parameters.ts index ab1149b..d855815 100644 --- a/src/routes/validate/parameters.ts +++ b/src/routes/validate/parameters.ts @@ -6,7 +6,7 @@ export default class ParametersValidate { parameters.forEach(parameter => { if (parameter.range.hasOwnProperty('values')) { joiObject[parameter.name] = Joi.alternatives() - .try(Joi.string(), Joi.number(), Joi.boolean()) + .try(Joi.string().max(128), Joi.number(), Joi.boolean()) .valid(...parameter.range.values); } else if (parameter.range.hasOwnProperty('min') && parameter.range.hasOwnProperty('max')) { @@ -22,9 +22,19 @@ export default class ParametersValidate { joiObject[parameter.name] = Joi.number() .max(parameter.range.max); } + else if (parameter.range.hasOwnProperty('type')) { + switch (parameter.range.type) { + case 'array': + joiObject[parameter.name] = Joi.array(); + break; + default: + joiObject[parameter.name] = Joi.string().max(128); + break; + } + } else { joiObject[parameter.name] = Joi.alternatives() - .try(Joi.string(), Joi.number(), Joi.boolean()); + .try(Joi.string().max(128), Joi.number(), Joi.boolean()); } if (param === 'new') { joiObject[parameter.name] = joiObject[parameter.name].required() diff --git a/src/test/db.json b/src/test/db.json index 06ac3d6..01b06b3 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -243,6 +243,27 @@ "status": 10, "measurement_template": {"$oid":"300000000000000000000001"}, "__v": 0 + }, + { + "_id": {"$oid":"800000000000000000000002"}, + "condition_id": {"$oid":"700000000000000000000002"}, + "values": { + "weight %": 0.5, + "standard deviation": 0.2 + }, + "status": 10, + "measurement_template": {"$oid":"300000000000000000000002"}, + "__v": 0 + }, + { + "_id": {"$oid":"800000000000000000000003"}, + "condition_id": {"$oid":"700000000000000000000003"}, + "values": { + "val1": 1 + }, + "status": 0, + "measurement_template": {"$oid":"300000000000000000000003"}, + "__v": 0 } ], "treatment_templates": [ From 5bce7a1e988003a9b88bf9b5e6fdac801832b8f8 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Tue, 12 May 2020 17:37:01 +0200 Subject: [PATCH 26/83] DELETE method --- api/measurement.yaml | 6 +-- src/routes/measurement.spec.ts | 75 ++++++++++++++++++++++++++++++++++ src/routes/measurement.ts | 16 ++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/api/measurement.yaml b/api/measurement.yaml index 7d1c0c4..4386a15 100644 --- a/api/measurement.yaml +++ b/api/measurement.yaml @@ -22,7 +22,7 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: TODO change measurement + summary: change measurement description: 'Auth: basic, levels: write, maintain, dev, admin' x-doc: status is reset to 0 on any changes tags: @@ -55,7 +55,7 @@ 500: $ref: 'api.yaml#/components/responses/500' delete: - summary: TODO delete measurement + summary: delete measurement description: 'Auth: basic, levels: write, maintain, dev, admin' tags: - /measurement @@ -77,7 +77,7 @@ /measurement/new: post: - summary: TODO add measurement + summary: add measurement description: 'Auth: basic, levels: write, maintain, dev, admin' x-doc: 'Adds status: 0 automatically' tags: diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index 08b27ef..bba7ca8 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -223,6 +223,81 @@ describe('/measurement', () => { }); }); + describe('DELETE /measurement/{id}', () => { + it('sets the status to deleted', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + MeasurementModel.findById('800000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('status', -1); + done(); + }); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 401, + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000001', + auth: {basic: 'user'}, + httpStatus: 403, + }); + }); + it('rejects deleting a measurement for a write user who did not create this measurement', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 403, + }); + }); + it('accepts deleting a measurement of another user for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + res: {status: 'OK'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000h00000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404, + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/000000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404, + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000001', + httpStatus: 401, + }); + }); + }); + describe('POST /measurement/new', () => { it('returns the right measurement', done => { TestHelper.request(server, done, { diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index a340a31..85bea0e 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -55,6 +55,22 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { }); }); +router.delete('/measurement/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + MeasurementModel.findById(req.params.id).lean().exec(async (err, data) => { + if (err) return next(err); + if (!data) { + res.status(404).json({status: 'Not found'}); + } + if (!await conditionIdCheck(data, req, res, next)) return; + await MeasurementModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec(async err => { + if (err) return next(err); + res.json({status: 'OK'}); + }); + }); +}); + router.post('/measurement/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; From f77d39af3478afdae181ef1ef38ef6d4026e3e1c Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Wed, 13 May 2020 09:56:44 +0200 Subject: [PATCH 27/83] adjusted condition --- api/measurement.yaml | 2 +- src/models/condition.ts | 3 +- src/models/measurement.ts | 4 +-- src/routes/condition.spec.ts | 51 ++++++++++++++++++++++++++++------ src/routes/condition.ts | 12 +++++--- src/routes/measurement.spec.ts | 18 ++++++++++++ src/routes/measurement.ts | 10 +++---- src/test/db.json | 4 +++ 8 files changed, 82 insertions(+), 22 deletions(-) diff --git a/api/measurement.yaml b/api/measurement.yaml index 4386a15..d9fcd40 100644 --- a/api/measurement.yaml +++ b/api/measurement.yaml @@ -2,7 +2,7 @@ parameters: - $ref: 'api.yaml#/components/parameters/Id' get: - summary: TODO measurement values by id + summary: measurement values by id description: 'Auth: all, levels: read, write, maintain, dev, admin' tags: - /measurement diff --git a/src/models/condition.ts b/src/models/condition.ts index 1e24daf..e0f79da 100644 --- a/src/models/condition.ts +++ b/src/models/condition.ts @@ -6,7 +6,8 @@ const ConditionSchema = new mongoose.Schema({ sample_id: {type: mongoose.Schema.Types.ObjectId, ref: SampleModel}, number: String, parameters: mongoose.Schema.Types.Mixed, - treatment_template: {type: mongoose.Schema.Types.ObjectId, ref: TreatmentTemplateModel} + treatment_template: {type: mongoose.Schema.Types.ObjectId, ref: TreatmentTemplateModel}, + status: Number }); export default mongoose.model('condition', ConditionSchema); \ No newline at end of file diff --git a/src/models/measurement.ts b/src/models/measurement.ts index 401103b..ac0ef20 100644 --- a/src/models/measurement.ts +++ b/src/models/measurement.ts @@ -5,8 +5,8 @@ import MeasurementTemplateModel from './measurement_template'; const MeasurementSchema = new mongoose.Schema({ condition_id: {type: mongoose.Schema.Types.ObjectId, ref: ConditionModel}, values: mongoose.Schema.Types.Mixed, - status: Number, - measurement_template: {type: mongoose.Schema.Types.ObjectId, ref: MeasurementTemplateModel} + measurement_template: {type: mongoose.Schema.Types.ObjectId, ref: MeasurementTemplateModel}, + status: Number }); export default mongoose.model('measurement', MeasurementSchema); \ No newline at end of file diff --git a/src/routes/condition.spec.ts b/src/routes/condition.spec.ts index ec71ac3..2967108 100644 --- a/src/routes/condition.spec.ts +++ b/src/routes/condition.spec.ts @@ -1,7 +1,7 @@ import should from 'should/as-function'; import ConditionModel from '../models/condition'; import TestHelper from "../test/helper"; -// TODO: status + describe('/condition', () => { let server; @@ -70,8 +70,32 @@ describe('/condition', () => { url: '/condition/700000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {parameters: {material: 'copper', weeks: 3}}, - res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}} + req: {parameters: {material: 'copper', weeks: 3}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}}); + ConditionModel.findById('700000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('status', 10); + done(); + }); + }); + }); + it('keeps only one unchanged parameter', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {parameters: {material: 'copper'}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}}); + ConditionModel.findById('700000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('status', 10); + done(); + }); }); }); it('changes the given properties', done => { @@ -86,9 +110,11 @@ describe('/condition', () => { should(res.body).be.eql({_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'hot air', weeks: 10}}); ConditionModel.findById('700000000000000000000001').lean().exec((err, data: any) => { if (err) return done(err); + should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', 'status', '__v'); should(data.sample_id.toString()).be.eql('400000000000000000000001'); should(data).have.property('number', 'B1'); should(data.treatment_template.toString()).be.eql('200000000000000000000001'); + should(data).have.property('status', 0); should(data).have.property('parameters'); should(data.parameters).have.property('material', 'hot air'); should(data.parameters).have.property('weeks', 10); @@ -205,7 +231,7 @@ describe('/condition', () => { // TODO: rewrite delete methods -> set status for every database collection describe('DELETE /condition/{id}', () => { - it('deletes the condition', done => { + it('sets the status to deleted', done => { TestHelper.request(server, done, { method: 'delete', url: '/condition/700000000000000000000002', @@ -214,14 +240,21 @@ describe('/condition', () => { }).end((err, res) => { if (err) return done(err); should(res.body).be.eql({status: 'OK'}); - ConditionModel.findById('700000000000000000000002').lean().exec((err, data) => { + ConditionModel.findById('700000000000000000000002').lean().exec((err, data: any) => { if (err) return done(err); - should(data).be.null(); + should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', 'status', '__v'); + should(data.sample_id.toString()).be.eql('400000000000000000000002'); + should(data).have.property('number', 'B1'); + should(data.treatment_template.toString()).be.eql('200000000000000000000001'); + should(data).have.property('status', -1); + should(data).have.property('parameters'); + should(data.parameters).have.property('material', 'copper'); + should(data.parameters).have.property('weeks', 3); done(); }); }); }); - it('rejects a deleting a condition referenced by measurements'); + it('rejects a deleting a condition referenced by measurements'); // TODO it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'delete', @@ -315,11 +348,11 @@ describe('/condition', () => { if (err) return done(err); ConditionModel.findById(res.body._id).lean().exec((err, data: any) => { if (err) return done(err); - should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', '__v'); - should(data).have.property('_id'); + should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', 'status', '__v'); should(data.sample_id.toString()).be.eql('400000000000000000000002'); should(data).have.property('number', 'B2'); should(data.treatment_template.toString()).be.eql('200000000000000000000001'); + should(data).have.property('status', 0); should(data).have.property('parameters'); should(data.parameters).have.property('material', 'hot air'); should(data.parameters).have.property('weeks', 10); diff --git a/src/routes/condition.ts b/src/routes/condition.ts index a5639e6..8acc2f2 100644 --- a/src/routes/condition.ts +++ b/src/routes/condition.ts @@ -1,5 +1,6 @@ import express from 'express'; import mongoose from 'mongoose'; +import _ from 'lodash'; import ConditionValidate from './validate/condition'; import ParametersValidate from './validate/parameters'; @@ -30,7 +31,6 @@ router.put('/condition/' + IdValidate.parameter(), async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; const {error, value: condition} = ConditionValidate.input(req.body, 'change'); - console.log(error); if (error) return res400(error, res); const data = await ConditionModel.findById(req.params.id).lean().exec().catch(err => {next(err);}) as any; @@ -40,11 +40,15 @@ router.put('/condition/' + IdValidate.parameter(), async (req, res, next) => { if (!data) { res.status(404).json({status: 'Not found'}); } + // add properties needed for sampleIdCheck condition.treatment_template = data.treatment_template; condition.sample_id = data.sample_id; if (!await sampleIdCheck(condition, req, res, next)) return; if (condition.parameters) { - condition.parameters = Object.assign(data.parameters, condition.parameters); + condition.parameters = _.assign({}, data.parameters, condition.parameters); + if (!_.isEqual(condition.parameters, data.parameters)) { + condition.status = 0; + } } if (!await treatmentCheck(condition, 'change', res, next)) return; @@ -63,7 +67,7 @@ router.delete('/condition/' + IdValidate.parameter(), (req, res, next) => { res.status(404).json({status: 'Not found'}); } if (!await sampleIdCheck(data, req, res, next)) return; - await ConditionModel.findByIdAndDelete(req.params.id).lean().exec(async err => { + await ConditionModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec(err => { if (err) return next(err); res.json({status: 'OK'}); }); @@ -80,6 +84,7 @@ router.post('/condition/new', async (req, res, next) => { if (!await numberCheck(condition, res, next)) return; if (!await treatmentCheck(condition, 'new', res, next)) return; + condition.status = 0; await new ConditionModel(condition).save((err, data) => { if (err) return next(err); res.json(ConditionValidate.output(data.toObject())); @@ -119,7 +124,6 @@ async function treatmentCheck (condition, param, res, next) { // validate parameters const {error, value: ignore} = ParametersValidate.input(condition.parameters, treatmentData.parameters, param); - console.log(error); if (error) {res400(error, res); return false;} return true; } \ No newline at end of file diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index bba7ca8..7a604d2 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -74,6 +74,24 @@ describe('/measurement', () => { if (err) return done(err); should(res.body).be.eql({_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'}); MeasurementModel.findById('800000000000000000000001').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status', 10); + done(); + }); + }); + }); + it('keeps only one unchanged value', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {values: {'weight %': 0.5}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '800000000000000000000002', condition_id: '700000000000000000000002', values: {'weight %': 0.5, 'standard deviation': 0.2}, measurement_template: '300000000000000000000002'}); + MeasurementModel.findById('800000000000000000000002').lean().exec((err, data: any) => { + if (err) return done(err); should(data).have.property('status', 10); done(); }); diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index 85bea0e..4aa89c1 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -41,12 +41,12 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { // add properties needed for conditionIdCheck measurement.measurement_template = data.measurement_template; measurement.condition_id = data.condition_id; - if (measurement.hasOwnProperty('values') && !_.isEqual(measurement.values, data.values)) { - measurement.status = 0; - } if (!await conditionIdCheck(measurement, req, res, next)) return; if (measurement.values) { - measurement.values = Object.assign(data.values, measurement.values); + measurement.values = _.assign({}, data.values, measurement.values); + if (!_.isEqual(measurement.values, data.values)) { + measurement.status = 0; + } } if (!await templateCheck(measurement, 'change', res, next)) return; await MeasurementModel.findByIdAndUpdate(req.params.id, measurement, {new: true}).lean().exec((err, data) => { @@ -64,7 +64,7 @@ router.delete('/measurement/' + IdValidate.parameter(), (req, res, next) => { res.status(404).json({status: 'Not found'}); } if (!await conditionIdCheck(data, req, res, next)) return; - await MeasurementModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec(async err => { + await MeasurementModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec(err => { if (err) return next(err); res.json({status: 'OK'}); }); diff --git a/src/test/db.json b/src/test/db.json index 01b06b3..621b385 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -193,6 +193,7 @@ "weeks": 3 }, "treatment_template": {"$oid":"200000000000000000000001"}, + "status": 10, "__v": 0 }, { @@ -204,6 +205,7 @@ "weeks": 3 }, "treatment_template": {"$oid":"200000000000000000000001"}, + "status": 10, "__v": 0 }, { @@ -215,6 +217,7 @@ "weeks": 3 }, "treatment_template": {"$oid":"200000000000000000000001"}, + "status": 10, "__v": 0 }, { @@ -226,6 +229,7 @@ "weeks": 5 }, "treatment_template": {"$oid":"200000000000000000000001"}, + "status": 10, "__v": 0 } ], From 478660573dc689373c0d836fe9dc62a380fddf2a Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Wed, 13 May 2020 12:06:28 +0200 Subject: [PATCH 28/83] adjusted sample --- api/condition.yaml | 4 ++ api/measurement.yaml | 2 + api/sample.yaml | 6 +++ src/models/sample.ts | 4 +- src/routes/sample.spec.ts | 82 ++++++++++++++++++++++++++++++--------- src/routes/sample.ts | 19 +++++---- src/test/db.json | 20 ++++++++-- 7 files changed, 103 insertions(+), 34 deletions(-) diff --git a/api/condition.yaml b/api/condition.yaml index 696aa4d..f924707 100644 --- a/api/condition.yaml +++ b/api/condition.yaml @@ -4,6 +4,7 @@ get: summary: condition by id description: 'Auth: all, levels: read, write, maintain, dev, admin' + x-doc: status handling (accessible (only for maintain/admin))? # TODO tags: - /condition responses: @@ -24,6 +25,7 @@ put: summary: change condition description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to reference samples created by another user' + x-doc: status is reset to 0 on any changes tags: - /condition security: @@ -61,6 +63,7 @@ delete: summary: delete condition description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: sets status to -1 tags: - /condition security: @@ -83,6 +86,7 @@ post: summary: add condition description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to reference samples created by another user' + x-doc: 'Adds status: 0 automatically' tags: - /condition security: diff --git a/api/measurement.yaml b/api/measurement.yaml index d9fcd40..2882883 100644 --- a/api/measurement.yaml +++ b/api/measurement.yaml @@ -4,6 +4,7 @@ get: summary: measurement values by id description: 'Auth: all, levels: read, write, maintain, dev, admin' + x-doc: status handling (accessible (only for maintain/admin))? # TODO tags: - /measurement responses: @@ -57,6 +58,7 @@ delete: summary: delete measurement description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: sets status to -1 tags: - /measurement security: diff --git a/api/sample.yaml b/api/sample.yaml index 4d2817b..9539053 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -2,6 +2,7 @@ get: summary: all samples in overview description: 'Auth: all, levels: read, write, maintain, dev, admin' + x-doc: returns only samples with status 10 # TODO: methods /samples/new|deleted tags: - /sample responses: @@ -23,6 +24,7 @@ get: summary: TODO sample details description: 'Auth: all, levels: read, write, maintain, dev, admin' + x-doc: status handling (accessible (only for maintain/admin))? # TODO tags: - /sample responses: @@ -43,6 +45,7 @@ put: summary: change sample description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to edit samples created by another user' + x-doc: status is reset to 0 on any changes tags: - /sample security: @@ -73,6 +76,7 @@ delete: summary: delete sample description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to edit samples created by another user' + x-doc: sets status to -1, notes and references to this sample are also kept, only note_fields are updated accordingly tags: - /sample security: @@ -95,6 +99,7 @@ post: summary: add sample description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: 'Adds status: 0 automatically' tags: - /sample security: @@ -125,6 +130,7 @@ get: summary: list all existing field names for custom notes fields description: 'Auth: all, levels: read, write, maintain, dev, admin' + x-doc: integrity has to be ensured # TODO: implement mechanism to regularly check note_fields tags: - /sample responses: diff --git a/src/models/sample.ts b/src/models/sample.ts index 81dcc28..9e5353b 100644 --- a/src/models/sample.ts +++ b/src/models/sample.ts @@ -9,10 +9,10 @@ const SampleSchema = new mongoose.Schema({ type: String, color: String, batch: String, - validated: Boolean, material_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialModel}, note_id: {type: mongoose.Schema.Types.ObjectId, ref: NoteModel}, - user_id: {type: mongoose.Schema.Types.ObjectId, ref: UserModel} + user_id: {type: mongoose.Schema.Types.ObjectId, ref: UserModel}, + status: Number }); export default mongoose.model('sample', SampleSchema); \ No newline at end of file diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index d74703d..99f6344 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -5,7 +5,6 @@ import NoteFieldModel from '../models/note_field'; import TestHelper from "../test/helper"; // TODO: generate sample number // TODO: think again which parameters are required at POST -// TODO: status describe('/sample', () => { let server; @@ -23,7 +22,7 @@ describe('/sample', () => { }).end((err, res) => { if (err) return done(err); const json = require('../test/db.json'); - should(res.body).have.lengthOf(json.collections.samples.length); + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === 10).length); should(res.body).matchEach(material => { should(material).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); should(material).have.property('_id').be.type('string'); @@ -47,7 +46,7 @@ describe('/sample', () => { }).end((err, res) => { if (err) return done(err); const json = require('../test/db.json'); - should(res.body).have.lengthOf(json.collections.samples.length); + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === 10).length); should(res.body).matchEach(material => { should(material).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); should(material).have.property('_id').be.type('string'); @@ -88,8 +87,41 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', notes: {}}, - res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} + req: {number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', notes: {}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}); + SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', 'status', '__v'); + should(data).have.property('_id'); + should(data).have.property('number', '1'); + should(data).have.property('color', 'black'); + should(data).have.property('type', 'granulate'); + should(data).have.property('batch', ''); + should(data.material_id.toString()).be.eql('100000000000000000000004'); + should(data.user_id.toString()).be.eql('000000000000000000000002'); + should(data).have.property('status', 10); + should(data).have.property('note_id', null); + done(); + }); + }); + }); + it('keeps only one unchanged parameter', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {type: 'granulate'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}); + SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.property('status', 10); + done(); + }); }); }); it('changes the given properties', done => { @@ -103,15 +135,15 @@ describe('/sample', () => { if (err) return done (err); SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { if (err) return done (err); - should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'validated', 'material_id', 'note_id', 'user_id', '__v'); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', 'status', '__v'); should(data).have.property('_id'); should(data).have.property('number', '10'); should(data).have.property('color', 'signalviolet'); should(data).have.property('type', 'part'); should(data).have.property('batch', '114531'); - should(data).have.property('validated').be.type('boolean'); should(data.material_id.toString()).be.eql('100000000000000000000002'); should(data.user_id.toString()).be.eql('000000000000000000000002'); + should(data).have.property('status', 0); should(data).have.property('note_id'); NoteModel.findById(data.note_id).lean().exec((err, data: any) => { if (err) return done (err); @@ -123,7 +155,7 @@ describe('/sample', () => { should(data.sample_references[0]).have.property('relation', 'part to this sample'); done(); }); - }) + }); }); }); it('adjusts the note_fields correctly', done => { @@ -315,7 +347,7 @@ describe('/sample', () => { }); describe('DELETE /sample/{id}', () => { - it('deletes the sample', done => { + it('sets the status to deleted', done => { TestHelper.request(server, done, { method: 'delete', url: '/sample/400000000000000000000001', @@ -324,14 +356,23 @@ describe('/sample', () => { }).end((err, res) => { if (err) return done(err); should(res.body).be.eql({status: 'OK'}); - SampleModel.findById('400000000000000000000001').lean().exec((err, data) => { + SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { if (err) return done(err); - should(data).be.null(); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', 'status', '__v'); + should(data).have.property('_id'); + should(data).have.property('number', '1'); + should(data).have.property('color', 'black'); + should(data).have.property('type', 'granulate'); + should(data).have.property('batch', ''); + should(data.material_id.toString()).be.eql('100000000000000000000004'); + should(data.user_id.toString()).be.eql('000000000000000000000002'); + should(data).have.property('status', -1); + should(data).have.property('note_id', null); done(); }); }); }); - it('deletes the notes of the sample', done => { + it('keeps the notes of the sample', done => { TestHelper.request(server, done, { method: 'delete', url: '/sample/400000000000000000000002', @@ -342,7 +383,9 @@ describe('/sample', () => { should(res.body).be.eql({status: 'OK'}); NoteModel.findById('500000000000000000000001').lean().exec((err, data) => { if (err) return done(err); - should(data).be.null(); + should(data).have.only.keys('_id', 'comment', 'sample_references', '__v'); + should(data).have.property('comment', 'Stoff gesperrt'); + should(data).have.property('sample_references').with.lengthOf(0); done(); }); }); @@ -367,7 +410,7 @@ describe('/sample', () => { }); }); }); - it('resets references to this sample', done => { + it('keeps references to this sample', done => { TestHelper.request(server, done, { method: 'delete', url: '/sample/400000000000000000000003', @@ -377,10 +420,12 @@ describe('/sample', () => { if (err) return done(err); should(res.body).be.eql({status: 'OK'}); setTimeout(() => { // background action takes some time before we can check - NoteModel.findById('500000000000000000000003').lean().exec((err, data) => { + NoteModel.findById('500000000000000000000003').lean().exec((err, data: any) => { if (err) return done(err); console.log(data); - should(data).have.property('sample_references').with.lengthOf(0); + should(data).have.property('sample_references').with.lengthOf(1); + should(data.sample_references[0].id.toString()).be.eql('400000000000000000000003'); + should(data.sample_references[0]).have.property('relation', 'part to sample'); done(); }); }, 100); @@ -398,7 +443,7 @@ describe('/sample', () => { should(res.body).be.eql({status: 'OK'}); SampleModel.findById('400000000000000000000001').lean().exec((err, data) => { if (err) return done(err); - should(data).be.null(); + should(data).have.property('status', -1); done(); }); }); @@ -486,7 +531,7 @@ describe('/sample', () => { SampleModel.find({number: 'Rng172'}).lean().exec((err, data: any) => { if (err) return done (err); should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', '__v'); + should(data[0]).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', 'status', '__v'); should(data[0]).have.property('_id'); should(data[0]).have.property('number', 'Rng172'); should(data[0]).have.property('color', 'black'); @@ -494,6 +539,7 @@ describe('/sample', () => { should(data[0]).have.property('batch', '1560237365'); should(data[0].material_id.toString()).be.eql('100000000000000000000001'); should(data[0].user_id.toString()).be.eql('000000000000000000000002'); + should(data[0]).have.property('status', 0); should(data[0]).have.property('note_id'); NoteModel.findById(data[0].note_id).lean().exec((err, data: any) => { if (err) return done (err); diff --git a/src/routes/sample.ts b/src/routes/sample.ts index abc5747..a6e9ced 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -16,7 +16,7 @@ const router = express.Router(); router.get('/samples', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - SampleModel.find({}).lean().exec((err, data) => { + SampleModel.find({status: 10}).lean().exec((err, data) => { if (err) return next(err); res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors }) @@ -66,6 +66,11 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { delete sample.notes; sample.note_id = data._id; } + + // check for changes + if (!_.isEqual(_.pick(IdValidate.stringify(sampleData), _.keys(sample)), _.omit(sample, ['notes']))) { + sample.status = 0; + } SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).lean().exec((err, data) => { if (err) return next(err); res.json(SampleValidate.output(data)); @@ -85,22 +90,15 @@ router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { // only maintain and admin are allowed to edit other user's data if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; - SampleModel.findByIdAndDelete(req.params.id).lean().exec(err => { // delete sample + SampleModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec(err => { // set sample status if (err) return next(err); if (sampleData.note_id !== null) { - NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec((err, data: any) => { // delete notes + NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { // find notes to update note_fields if (err) return next(err); - console.log(data); if (data.hasOwnProperty('custom_fields')) { // update note_fields customFieldsChange(Object.keys(data.custom_fields), -1); } res.json({status: 'OK'}); - NoteModel.updateMany({'sample_references.id': req.params.id}, {$unset: {'sample_references.$': null}}).lean().exec(err => { // remove sample_references - if (err) console.error(err); - NoteModel.collection.updateMany({sample_references: null}, {$pull: {sample_references: null}}, err => { // only works with native MongoDB driver somehow - if (err) console.error(err); - }); - }); }); } else { @@ -124,6 +122,7 @@ router.post('/sample/new', async (req, res, next) => { customFieldsChange(Object.keys(sample.notes.custom_fields), 1); } + sample.status = 0; new NoteModel(sample.notes).save((err, data) => { if (err) return next(err); delete sample.notes; diff --git a/src/test/db.json b/src/test/db.json index 621b385..a5a347a 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -7,10 +7,10 @@ "type": "granulate", "color": "black", "batch": "", - "validated": true, "material_id": {"$oid":"100000000000000000000004"}, "note_id": null, "user_id": {"$oid":"000000000000000000000002"}, + "status": 10, "__v": 0 }, { @@ -19,10 +19,10 @@ "type": "granulate", "color": "natural", "batch": "1560237365", - "validated": true, "material_id": {"$oid":"100000000000000000000001"}, "note_id": {"$oid":"500000000000000000000001"}, "user_id": {"$oid":"000000000000000000000002"}, + "status": 10, "__v": 0 }, { @@ -31,10 +31,10 @@ "type": "part", "color": "black", "batch": "1704-005", - "validated": false, "material_id": {"$oid":"100000000000000000000005"}, "note_id": {"$oid":"500000000000000000000002"}, "user_id": {"$oid":"000000000000000000000003"}, + "status": 0, "__v": 0 }, { @@ -43,10 +43,22 @@ "type": "granulate", "color": "black", "batch": "1653000308", - "validated": false, "material_id": {"$oid":"100000000000000000000005"}, "note_id": {"$oid":"500000000000000000000003"}, "user_id": {"$oid":"000000000000000000000003"}, + "status": 0, + "__v": 0 + }, + { + "_id": {"$oid":"400000000000000000000005"}, + "number": "33", + "type": "granulate", + "color": "black", + "batch": "1653000308", + "material_id": {"$oid":"100000000000000000000005"}, + "note_id": {"$oid":"500000000000000000000003"}, + "user_id": {"$oid":"000000000000000000000003"}, + "status": -1, "__v": 0 } ], From 806b77eecf2e0f24e65a604b2c6f6d736e5e2903 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Wed, 13 May 2020 14:18:15 +0200 Subject: [PATCH 29/83] adjusted material --- api/api.yaml | 1 + api/material.yaml | 5 +++ src/models/material.ts | 3 +- src/routes/material.spec.ts | 50 +++++++++++++++++++----- src/routes/material.ts | 76 ++++++++++++++++++------------------- src/routes/template.ts | 1 + src/test/db.json | 22 +++++++++++ 7 files changed, 109 insertions(+), 49 deletions(-) diff --git a/api/api.yaml b/api/api.yaml index 0c17f4d..f890477 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -34,6 +34,7 @@ info:
  • 0: newly added/changed
  • 10: validated
  • + Bitbucket repository # TODO: Link to new documentation page diff --git a/api/material.yaml b/api/material.yaml index 5e8bc13..6a86b38 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -2,6 +2,7 @@ get: summary: lists all materials description: 'Auth: all, levels: read, write, maintain, dev, admin' + x-doc: returns only materials with status 10 # TODO: methods /materials/new|deleted tags: - /material responses: @@ -24,6 +25,7 @@ get: summary: get material details description: 'Auth: all, levels: read, write, maintain, dev, admin' + x-doc: status handling (accessible (only for maintain/admin))? # TODO tags: - /material responses: @@ -42,6 +44,7 @@ put: summary: change material description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: status is reset to 0 on any changes tags: - /material security: @@ -72,6 +75,7 @@ delete: summary: delete material description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: sets status to -1 tags: - /material security: @@ -94,6 +98,7 @@ post: summary: add material description: 'Auth: basic, levels: write, maintain, dev, admin' + x-doc: 'Adds status: 0 automatically' tags: - /material security: diff --git a/src/models/material.ts b/src/models/material.ts index 530f8f0..a5378e0 100644 --- a/src/models/material.ts +++ b/src/models/material.ts @@ -10,7 +10,8 @@ const MaterialSchema = new mongoose.Schema({ numbers: [{ color: String, number: Number - }] + }], + status: Number }); export default mongoose.model('material', MaterialSchema); \ No newline at end of file diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 59bdd4a..e7767de 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -2,6 +2,8 @@ import should from 'should/as-function'; import MaterialModel from '../models/material'; import TestHelper from "../test/helper"; // TODO: status +// TODO: numbers with color only (no number) +// TODO: deal with numbers with leading zeros describe('/material', () => { let server; @@ -19,7 +21,8 @@ describe('/material', () => { }).end((err, res) => { if (err) return done(err); const json = require('../test/db.json'); - should(res.body).have.lengthOf(json.collections.materials.length); + console.log(res.body); + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === 10).length); should(res.body).matchEach(material => { should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); should(material).have.property('_id').be.type('string'); @@ -47,7 +50,7 @@ describe('/material', () => { }).end((err, res) => { if (err) return done(err); const json = require('../test/db.json'); - should(res.body).have.lengthOf(json.collections.materials.length); + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === 10).length); should(res.body).matchEach(material => { should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); should(material).have.property('_id').be.type('string'); @@ -136,8 +139,32 @@ describe('/material', () => { url: '/material/100000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}]}, - res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}]} + req: {name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}, {color: 'natural', number: 5514263422}]} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}, {color: 'natural', number: 5514263422}]}); + MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('status', 10); + done(); + }); + }); + }); + it('keeps only one unchanged property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'Stanyl TW 200 F8'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}, {color: 'natural', number: 5514263422}]}); + MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('status', 10); + done(); + }); }); }); it('changes the given properties', done => { @@ -155,8 +182,7 @@ describe('/material', () => { if (err) return done(err); data._id = data._id.toString(); data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); - should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: '0', glass_fiber: '35', carbon_fiber: '0', numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}], __v: 0} - ); + should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: '0', glass_fiber: '35', carbon_fiber: '0', numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}], status: 0, __v: 0}); done(); }); }); @@ -268,7 +294,7 @@ describe('/material', () => { }); describe('DELETE /material/{id}', () => { - it('deletes the material', done => { + it('sets the status to deleted', done => { TestHelper.request(server, done, { method: 'delete', url: '/material/100000000000000000000002', @@ -277,9 +303,12 @@ describe('/material', () => { }).end((err, res) => { if (err) return done(err); should(res.body).be.eql({status: 'OK'}); - MaterialModel.findById('100000000000000000000002').lean().exec((err, data) => { + MaterialModel.findById('100000000000000000000002').lean().exec((err, data: any) => { if (err) return done(err); - should(data).be.null(); + data._id = data._id.toString(); + data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); + should(data).be.eql({_id: '100000000000000000000002', name: 'Ultramid T KR 4355 G7', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}], status: -1, __v: 0} + ); done(); }); }); @@ -372,7 +401,7 @@ describe('/material', () => { MaterialModel.find({name: 'Crastin CE 2510'}).lean().exec((err, data: any) => { if (err) return done (err); should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', '__v'); + should(data[0]).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v'); should(data[0]).have.property('_id'); should(data[0]).have.property('name', 'Crastin CE 2510'); should(data[0]).have.property('supplier', 'Du Pont'); @@ -380,6 +409,7 @@ describe('/material', () => { should(data[0]).have.property('mineral', '0'); should(data[0]).have.property('glass_fiber', '30'); should(data[0]).have.property('carbon_fiber', '0'); + should(data[0]).have.property('status', 0); should(data[0].numbers).have.lengthOf(0); done(); }); diff --git a/src/routes/material.ts b/src/routes/material.ts index fdb0c47..7601796 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -8,13 +8,14 @@ import IdValidate from './validate/id'; import res400 from './validate/res400'; import mongoose from 'mongoose'; +// TODO: remove f() for await const router = express.Router(); router.get('/materials', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - MaterialModel.find({}).lean().exec((err, data) => { + MaterialModel.find({status: 10}).lean().exec((err, data) => { if (err) return next(err); res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors }); @@ -40,33 +41,24 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { const {error, value: material} = MaterialValidate.input(req.body, 'change'); if (error) return res400(error, res); - if (material.hasOwnProperty('name')) { - MaterialModel.find({name: material.name}).lean().exec((err, data) => { - if (err) return next(err); - if (data.length > 0 && data[0]._id != req.params.id) { - res.status(400).json({status: 'Material name already taken'}); - return; - } - else { - f(); - } - }); - } - else { - f(); - } + MaterialModel.findById(req.params.id).lean().exec(async (err, materialData: any) => { + if (!materialData) { + return res.status(404).json({status: 'Not found'}); + } + if (material.hasOwnProperty('name') && material.name !== materialData.name) { + if (!await nameCheck(material, res, next)) return; + } - function f() { // to resolve async - MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).lean().exec((err, data) => { + // check for changes + if (!_.isEqual(_.pick(IdValidate.stringify(materialData), _.keys(material)), material)) { + material.status = 0; + } + + await MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).lean().exec((err, data) => { if (err) return next(err); - if (data) { - res.json(MaterialValidate.output(data)); - } - else { - res.status(404).json({status: 'Not found'}); - } + res.json(MaterialValidate.output(data)); }); - } + }); }); router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { @@ -78,7 +70,7 @@ router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { if (data.length) { return res.status(400).json({status: 'Material still in use'}); } - MaterialModel.findByIdAndDelete(req.params.id).lean().exec((err, data) => { + MaterialModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec((err, data) => { if (err) return next(err); if (data) { res.json({status: 'OK'}); @@ -90,26 +82,34 @@ router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { }); }); -router.post('/material/new', (req, res, next) => { +router.post('/material/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; // validate input const {error, value: material} = MaterialValidate.input(req.body, 'new'); if (error) return res400(error, res); - MaterialModel.find({name: material.name}).lean().exec((err, data) => { - if (err) return next(err); - if (data.length > 0) { - res.status(400).json({status: 'Material name already taken'}); - return; - } + if (!await nameCheck(material, res, next)) return; - new MaterialModel(material).save((err, data) => { - if (err) return next(err); - res.json(MaterialValidate.output(data.toObject())); - }); + material.status = 0; + await new MaterialModel(material).save((err, data) => { + if (err) return next(err); + res.json(MaterialValidate.output(data.toObject())); }); }); -module.exports = router; \ No newline at end of file +module.exports = router; + + +async function nameCheck (material, res, next) { // check if name was already taken + const materialData = await MaterialModel.findOne({name: material.name}).lean().exec().catch(err => {next(err); return false;}) as any; + if (materialData instanceof Error) { + return false; + } + if (materialData) { // could not find material_id + res.status(400).json({status: 'Material name already taken'}); + return false; + } + return true; +} \ No newline at end of file diff --git a/src/routes/template.ts b/src/routes/template.ts index 2c0277c..5f1477c 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -6,6 +6,7 @@ import TemplateTreatmentModel from '../models/treatment_template'; import TemplateMeasurementModel from '../models/measurement_template'; import res400 from './validate/res400'; +// TODO: remove f() for await const router = express.Router(); diff --git a/src/test/db.json b/src/test/db.json index a5a347a..6ac2156 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -128,6 +128,7 @@ "number": 5514263422 } ], + "status": 10, "__v": 0 }, { @@ -148,6 +149,7 @@ "number": 5514612901 } ], + "status": 10, "__v": 0 }, { @@ -160,6 +162,7 @@ "carbon_fiber": 0, "numbers": [ ], + "status": 10, "__v": 0 }, { @@ -176,6 +179,7 @@ "number": 5513933405 } ], + "status": 10, "__v": 0 }, { @@ -192,6 +196,24 @@ "number": 5514262406 } ], + "status": 10, + "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000006"}, + "name": "PK-HM natural (4773)", + "supplier": "Akro-Plastic", + "group": "PK", + "mineral": 0, + "glass_fiber": 0, + "carbon_fiber": 0, + "numbers": [ + { + "color": "natural", + "number": 10000000 + } + ], + "status": -1, "__v": 0 } ], From 649a0b166e8d58d397a7d7c257e882bc7f310138 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Wed, 13 May 2020 17:28:18 +0200 Subject: [PATCH 30/83] adapted api doc --- api/schemas.yaml | 17 +++ api/template.yaml | 142 ++++++------------ package-lock.json | 55 +++++++ package.json | 1 + src/api.ts | 11 +- src/routes/material.spec.ts | 2 +- src/routes/material.ts | 2 +- src/routes/template.spec.ts | 285 +++++++++++++++--------------------- src/routes/template.ts | 28 ++-- src/test/db.json | 7 + 10 files changed, 269 insertions(+), 281 deletions(-) diff --git a/api/schemas.yaml b/api/schemas.yaml index 84722a5..3f5098c 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -145,6 +145,11 @@ Template: properties: name: type: string + example: humidity + version: + type: number + readOnly: true + example: 1 parameters: type: array items: @@ -152,8 +157,20 @@ Template: properties: name: type: string + example: kf range: type: object + example: + min: 0 + max: 2 + +TreatmentTemplate: + allOf: + - $ref: 'api.yaml#/components/schemas/Template' + properties: + number_prefix: + type: string + example: B Email: properties: diff --git a/api/template.yaml b/api/template.yaml index 5b362fb..37f374a 100644 --- a/api/template.yaml +++ b/api/template.yaml @@ -14,23 +14,15 @@ schema: type: array items: - $ref: 'api.yaml#/components/schemas/Template' - example: - _id: 5ea0450ed851c30a90e70894 - name: heat aging - parameters: - - name: method - range: - values: - - copper - - hot air + $ref: 'api.yaml#/components/schemas/TreatmentTemplate' 401: $ref: 'api.yaml#/components/responses/401' 500: $ref: 'api.yaml#/components/responses/500' -/template/treatment/{name}: + +/template/treatment/{id}: parameters: - - $ref: 'api.yaml#/components/parameters/Name' + - $ref: 'api.yaml#/components/parameters/Id' get: summary: treatment method details description: 'Auth: basic, levels: read, write, maintain, admin' @@ -44,17 +36,7 @@ content: application/json: schema: - allOf: - - $ref: 'api.yaml#/components/schemas/Template' - example: - _id: 5ea0450ed851c30a90e70894 - name: heat aging - parameters: - - name: method - range: - values: - - copper - - hot air + $ref: 'api.yaml#/components/schemas/TreatmentTemplate' 401: $ref: 'api.yaml#/components/responses/401' 404: @@ -62,8 +44,9 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: add/change treatment method + summary: change treatment method description: 'Auth: basic, levels: maintain, admin' + x-doc: With a change a new version is set, resulting in a new template with a new id tags: - /template security: @@ -73,33 +56,14 @@ content: application/json: schema: - allOf: - - $ref: 'api.yaml#/components/schemas/Template' - example: - name: heat aging - parameters: - - name: method - range: - values: - - copper - - hot air + $ref: 'api.yaml#/components/schemas/TreatmentTemplate' responses: 200: description: treatment details content: application/json: schema: - allOf: - - $ref: 'api.yaml#/components/schemas/Template' - example: - _id: 5ea0450ed851c30a90e70894 - name: heat aging - parameters: - - name: method - range: - values: - - copper - - hot air + $ref: 'api.yaml#/components/schemas/TreatmentTemplate' 400: $ref: 'api.yaml#/components/responses/400' 401: @@ -110,26 +74,37 @@ $ref: 'api.yaml#/components/responses/404' 500: $ref: 'api.yaml#/components/responses/500' - delete: - summary: delete treatment method + +/template/treatment/new: + post: + summary: add treatment method description: 'Auth: basic, levels: maintain, admin' tags: - /template security: - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/TreatmentTemplate' responses: 200: - $ref: 'api.yaml#/components/responses/Ok' + description: treatment details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/TreatmentTemplate' 400: $ref: 'api.yaml#/components/responses/400' 401: $ref: 'api.yaml#/components/responses/401' 403: $ref: 'api.yaml#/components/responses/403' - 404: - $ref: 'api.yaml#/components/responses/404' 500: $ref: 'api.yaml#/components/responses/500' + /template/measurements: get: summary: all available measurement methods @@ -147,21 +122,13 @@ type: array items: $ref: 'api.yaml#/components/schemas/Template' - example: - _id: 5ea0450ed851c30a90e70894 - name: humidity - parameters: - - name: kf - range: - min: 0 - max: 2 401: $ref: 'api.yaml#/components/responses/401' 500: $ref: 'api.yaml#/components/responses/500' -/template/measurement/{name}: +/template/measurement/{id}: parameters: - - $ref: 'api.yaml#/components/parameters/Name' + - $ref: 'api.yaml#/components/parameters/Id' get: summary: measurement method details description: 'Auth: basic, levels: read, write, maintain, admin' @@ -175,16 +142,7 @@ content: application/json: schema: - allOf: - - $ref: 'api.yaml#/components/schemas/Template' - example: - _id: 5ea0450ed851c30a90e70894 - name: humidity - parameters: - - name: kf - range: - min: 0 - max: 2 + $ref: 'api.yaml#/components/schemas/Template' 400: $ref: 'api.yaml#/components/responses/400' 401: @@ -194,7 +152,7 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: add/change measurement method + summary: change measurement method description: 'Auth: basic, levels: maintain, admin' tags: - /template @@ -205,32 +163,14 @@ content: application/json: schema: - allOf: - - $ref: 'api.yaml#/components/schemas/Template' - example: - _id: 5ea0450ed851c30a90e70894 - name: humidity - parameters: - - name: kf - range: - min: 0 - max: 2 + $ref: 'api.yaml#/components/schemas/Template' responses: 200: description: measurement details content: application/json: schema: - allOf: - - $ref: 'api.yaml#/components/schemas/Template' - example: - _id: 5ea0450ed851c30a90e70894 - name: humidity - parameters: - - name: kf - range: - min: 0 - max: 2 + $ref: 'api.yaml#/components/schemas/Template' 400: $ref: 'api.yaml#/components/responses/400' 401: @@ -241,23 +181,33 @@ $ref: 'api.yaml#/components/responses/404' 500: $ref: 'api.yaml#/components/responses/500' - delete: - summary: delete measurement method + +/template/measurement/new: + post: + summary: add measurement method description: 'Auth: basic, levels: maintain, admin' tags: - /template security: - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Template' responses: 200: - $ref: 'api.yaml#/components/responses/Ok' + description: measurement details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Template' 400: $ref: 'api.yaml#/components/responses/400' 401: $ref: 'api.yaml#/components/responses/401' 403: $ref: 'api.yaml#/components/responses/403' - 404: - $ref: 'api.yaml#/components/responses/404' 500: $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4629b37..4c3c77d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,30 @@ "js-yaml": "^3.13.1" } }, + "@apidevtools/openapi-schemas": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.0.3.tgz", + "integrity": "sha512-QoPaxGXfgqgGpK1p21FJ400z56hV681a8DOcZt3J5z0WIHgFeaIZ4+6bX5ATqmOoCpRCsH4ITEwKaOyFMz7wOA==" + }, + "@apidevtools/swagger-methods": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.1.tgz", + "integrity": "sha512-1Vlm18XYW6Yg7uHunroXeunWz5FShPFAdxBbPy8H6niB2Elz9QQsCoYHMbcc11EL1pTxaIr9HXz2An/mHXlX1Q==" + }, + "@apidevtools/swagger-parser": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-9.0.1.tgz", + "integrity": "sha512-Irqybg4dQrcHhZcxJc/UM4vO7Ksoj1Id5e+K94XUOzllqX1n47HEA50EKiXTCQbykxuJ4cYGIivjx/MRSTC5OA==", + "requires": { + "@apidevtools/json-schema-ref-parser": "^8.0.0", + "@apidevtools/openapi-schemas": "^2.0.2", + "@apidevtools/swagger-methods": "^3.0.0", + "@jsdevtools/ono": "^7.1.0", + "call-me-maybe": "^1.0.1", + "openapi-types": "^1.3.5", + "z-schema": "^4.2.2" + } + }, "@babel/code-frame": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", @@ -1353,6 +1377,16 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, "log-symbols": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", @@ -1743,6 +1777,11 @@ "wrappy": "1" } }, + "openapi-types": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-1.3.5.tgz", + "integrity": "sha512-11oi4zYorsgvg5yBarZplAqbpev5HkuVNPlZaPTknPDzAynq+lnJdXAmruGWP0s+dNYZS7bjM+xrTpJw7184Fg==" + }, "p-cancelable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", @@ -2505,6 +2544,11 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, + "validator": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-12.2.0.tgz", + "integrity": "sha512-jJfE/DW6tIK1Ek8nCfNFqt8Wb3nzMoAbocBF6/Icgg1ZFSBpObdnwVY2jQj6qUqzhx5jc71fpvBWyLGO7Xl+nQ==" + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2709,6 +2753,17 @@ "lodash": "^4.17.15", "yargs": "^13.3.0" } + }, + "z-schema": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-4.2.3.tgz", + "integrity": "sha512-zkvK/9TC6p38IwcrbnT3ul9in1UX4cm1y/VZSs4GHKIiDCrlafc+YQBgQBUdDXLAoZHf2qvQ7gJJOo6yT1LH6A==", + "requires": { + "commander": "^2.7.1", + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^12.0.0" + } } } } diff --git a/package.json b/package.json index e58c0a0..4ec763a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "license": "ISC", "dependencies": { "@apidevtools/json-schema-ref-parser": "^8.0.0", + "@apidevtools/swagger-parser": "^9.0.1", "@hapi/joi": "^17.1.1", "@types/bcrypt": "^3.0.0", "@types/body-parser": "^1.19.0", diff --git a/src/api.ts b/src/api.ts index 77a60ca..f85393f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,6 @@ import swagger from 'swagger-ui-express'; import jsonRefParser, {JSONSchema} from '@apidevtools/json-schema-ref-parser'; +import oasParser from '@apidevtools/swagger-parser'; // modifies the normal swagger-ui-express package @@ -19,7 +20,15 @@ export default class api { apiDoc = doc; apiDoc.paths = apiDoc.paths.allOf.reduce((s, e) => Object.assign(s, e)); // bundle routes apiDoc = this.resolveXDoc(apiDoc); - swagger.setup(apiDoc); + oasParser.validate(apiDoc, (err, api) => { + if (err) { + console.error(err); + } + else { + console.info('API ok, version ' + api.info.version); + swagger.setup(apiDoc); + } + }); }); return swagger.setup(apiDoc, {customCssUrl: '/static/styles/swagger.css'}) } diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index e7767de..0faf04e 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -1,7 +1,7 @@ import should from 'should/as-function'; import MaterialModel from '../models/material'; import TestHelper from "../test/helper"; -// TODO: status + // TODO: numbers with color only (no number) // TODO: deal with numbers with leading zeros diff --git a/src/routes/material.ts b/src/routes/material.ts index 7601796..a912f5e 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -8,7 +8,7 @@ import IdValidate from './validate/id'; import res400 from './validate/res400'; import mongoose from 'mongoose'; -// TODO: remove f() for await + const router = express.Router(); diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index d3f973a..3c05991 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -3,7 +3,8 @@ import TemplateTreatmentModel from '../models/treatment_template'; import TemplateMeasurementModel from '../models/measurement_template'; import TestHelper from "../test/helper"; -// TODO: remove DELETE methods, only updates possible +// TODO: convert name to id, criteria for new name, criteria for new version, criteria for prefix + describe('/template', () => { let server; before(done => TestHelper.before(done)); @@ -26,6 +27,8 @@ describe('/template', () => { should(treatment).have.only.keys('_id', 'name', 'parameters'); should(treatment).have.property('_id').be.type('string'); should(treatment).have.property('name').be.type('string'); + should(treatment).have.property('version').be.type('number'); + should(treatment).have.property('number_prefix').be.type('string'); should(treatment.parameters).matchEach(number => { should(number).have.only.keys('name', 'range'); should(number).have.property('name').be.type('string'); @@ -52,28 +55,28 @@ describe('/template', () => { }); }); - describe('GET /template/treatment/{name}', () => { + describe('GET /template/treatment/{id}', () => { it('returns the right treatment template', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/treatment/heat%20treatment', + url: '/template/treatment/200000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, number_prefix: 'A', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} }); }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/treatment/heat%20treatment', + url: '/template/treatment/200000000000000000000001', auth: {key: 'janedoe'}, httpStatus: 401 }); }); - it('rejects an unknown name', done => { + it('rejects an unknown id', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/treatment/xxx', + url: '/template/treatment/000000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 404 }); @@ -81,7 +84,7 @@ describe('/template', () => { it('rejects unauthorized requests', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/treatment/heat%20treatment', + url: '/template/treatment/200000000000000000000001', httpStatus: 401 }); }); @@ -91,38 +94,50 @@ describe('/template', () => { it('returns the right treatment template', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/heat%20treatment', + url: '/template/treatment/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {}, - res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, number_prefix: 'A', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} }); }); it('keeps unchanged properties', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/heat%20treatment', + url: '/template/treatment/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]}, - res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, number_prefix: 'A', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + }); + }); + it('keeps only one unchanged property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat treatment'}, + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, number_prefix: 'A', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} }); }); it('changes the given properties', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/heat%20treatment', + url: '/template/treatment/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '200000000000000000000001', name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}); + should(res.body).be.eql({_id: '200000000000000000000001', name: 'heat aging', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {min: 1}}]}); TemplateTreatmentModel.find({name: 'heat aging'}).lean().exec((err, data:any) => { if (err) return done(err); should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'parameters', '__v'); + should(data[0]).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters', '__v'); should(data[0]).have.property('name', 'heat aging'); + should(data[0]).have.property('version', 2); + should(data[0]).have.property('number_prefix', 'A'); should(data[0]).have.property('parameters').have.lengthOf(1); should(data[0].parameters[0]).have.property('name', 'time'); should(data[0].parameters[0]).have.property('range'); @@ -131,50 +146,122 @@ describe('/template', () => { }); }); }); + it('allows changing only one property'); // TODO: adapt PUT to other PUTs and do POST, everything for measurement too it('supports values ranges', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/heat%20treatment', + url: '/template/treatment/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {parameters: [{name: 'time', range: {values: [1, 2, 5]}}]}, - res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'time', range: {values: [1, 2, 5]}}]} + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {values: [1, 2, 5]}}]} }); }); it('supports min max ranges', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/heat%20treatment', + url: '/template/treatment/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {parameters: [{name: 'time', range: {min: 1, max: 11}}]}, - res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'time', range: {min: 1, max: 11}}]} + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {min: 1, max: 11}}]} }); }); it('supports empty ranges', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/heat%20treatment', + url: '/template/treatment/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {parameters: [{name: 'time', range: {}}]}, - res: {_id: '200000000000000000000001', name: 'heat treatment', parameters: [{name: 'time', range: {}}]} + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {}}]} }); }); - it('adds a new template for an unknown name', done => { + it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/heat%20aging', + url: '/template/treatment/2000000000h0000000000001', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/000000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + }); + }); + it('rejects already existing number prefixes', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {number_prefix: 'B', parameters: [{name: 'time', range: {min: 1}}]}, + res: {status: 'Number prefix already taken'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/200000000000000000000001', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/200000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/200000000000000000000001', + httpStatus: 401, + req: {} + }); + }); + }); + + describe('POST /template/treatment/new', () => { + it('returns the right treatment template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, number_prefix: 'A', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + }); + }); + it('stores the template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} - }).end(err => { + }).end((err, res) => { if (err) return done(err); + should(res.body).be.eql({_id: '200000000000000000000001', name: 'heat aging', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {min: 1}}]}); TemplateTreatmentModel.find({name: 'heat aging'}).lean().exec((err, data:any) => { if (err) return done(err); should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'parameters', '__v'); + should(data[0]).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters', '__v'); should(data[0]).have.property('name', 'heat aging'); + should(data[0]).have.property('version', 2); + should(data[0]).have.property('number_prefix', 'A'); + should(data[0]).have.property('parameters').have.lengthOf(1); should(data[0].parameters[0]).have.property('name', 'time'); should(data[0].parameters[0]).have.property('range'); should(data[0].parameters[0].range).have.property('min', 1); @@ -182,7 +269,7 @@ describe('/template', () => { }); }); }); - it('rejects a missing name for a new name', done => { + it('rejects a missing name', done => { TestHelper.request(server, done, { method: 'put', url: '/template/treatment/heat%20aging', @@ -192,7 +279,7 @@ describe('/template', () => { res: {status: 'Invalid body format', details: '"name" is required'} }); }); - it('rejects missing parameters for a new name', done => { + it('rejects missing parameters', done => { TestHelper.request(server, done, { method: 'put', url: '/template/treatment/heat%20aging', @@ -202,7 +289,7 @@ describe('/template', () => { res: {status: 'Invalid body format', details: '"parameters" is required'} }); }); - it('rejects a missing parameter name for a new name', done => { + it('rejects a missing parameter name', done => { TestHelper.request(server, done, { method: 'put', url: '/template/treatment/heat%20aging', @@ -212,7 +299,7 @@ describe('/template', () => { res: {status: 'Invalid body format', details: '"parameters[0].name" is required'} }); }); - it('rejects a missing parameter range for a new name', done => { + it('rejects a missing parameter range', done => { TestHelper.request(server, done, { method: 'put', url: '/template/treatment/heat%20aging', @@ -222,7 +309,7 @@ describe('/template', () => { res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} }); }); - it('rejects a an invalid parameter range property for a new name', done => { + it('rejects a an invalid parameter range property', done => { TestHelper.request(server, done, { method: 'put', url: '/template/treatment/heat%20aging', @@ -232,16 +319,6 @@ describe('/template', () => { res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} }); }); - it('rejects already existing names', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/template/treatment/heat%20treatment', - auth: {basic: 'admin'}, - httpStatus: 400, - req: {name: 'heat treatment 2', parameters: [{name: 'time', range: {min: 1}}]}, - res: {status: 'Template name already taken'} - }); - }); it('rejects wrong properties', done => { TestHelper.request(server, done, { method: 'put', @@ -252,83 +329,6 @@ describe('/template', () => { res: {status: 'Invalid body format', details: '"name" is required'} }); }); - it('rejects an API key', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/template/treatment/heat%20treatment', - auth: {key: 'admin'}, - httpStatus: 401, - req: {} - }); - }); - it('rejects requests from a write user', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/template/treatment/heat%20treatment', - auth: {basic: 'janedoe'}, - httpStatus: 403, - req: {} - }); - }); - it('rejects unauthorized requests', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/template/treatment/heat%20treatment', - httpStatus: 401, - req: {} - }); - }); - }); - - describe('DELETE /template/treatment/{name}', () => { - it('deletes the template', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/template/treatment/heat%20treatment', - auth: {basic: 'admin'}, - httpStatus: 200 - }).end((err, res) => { - if (err) return done(err); - should(res.body).be.eql({status: 'OK'}); - TemplateTreatmentModel.find({name: 'heat treatment'}).lean().exec((err, data:any) => { - if (err) return done(err); - should(data).have.lengthOf(0); - done(); - }); - }); - }); - it('rejects deleting a template still in use'); - it('rejects an API key', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/template/treatment/heat%20treatment', - auth: {key: 'admin'}, - httpStatus: 401 - }); - }); - it('rejects requests from a write user', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/template/treatment/heat%20treatment', - auth: {basic: 'janedoe'}, - httpStatus: 403 - }) - }); - it('returns 404 for an unknown name', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/template/treatment/xxx', - auth: {basic: 'admin'}, - httpStatus: 404 - }) - }); - it('rejects unauthorized requests', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/template/treatment/heat%20treatment', - httpStatus: 401 - }) - }); }); }); @@ -603,56 +603,5 @@ describe('/template', () => { }); }); }); - - describe('DELETE /template/measurement/{name}', () => { - it('deletes the template', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/template/measurement/spectrum', - auth: {basic: 'admin'}, - httpStatus: 200 - }).end((err, res) => { - if (err) return done(err); - should(res.body).be.eql({status: 'OK'}); - TemplateMeasurementModel.find({name: 'spectrum'}).lean().exec((err, data:any) => { - if (err) return done(err); - should(data).have.lengthOf(0); - done(); - }); - }); - }); - it('rejects deleting a template still in use'); - it('rejects an API key', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/template/measurement/spectrum', - auth: {key: 'admin'}, - httpStatus: 401 - }); - }); - it('rejects requests from a write user', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/template/measurement/spectrum', - auth: {basic: 'janedoe'}, - httpStatus: 403 - }) - }); - it('returns 404 for an unknown name', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/template/measurement/xxx', - auth: {basic: 'admin'}, - httpStatus: 404 - }) - }); - it('rejects unauthorized requests', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/template/measurement/spectrum', - httpStatus: 401 - }) - }); - }); }); }); \ No newline at end of file diff --git a/src/routes/template.ts b/src/routes/template.ts index 5f1477c..55088f9 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -71,20 +71,20 @@ router.put('/template/:collection(measurement|treatment)/:name', (req, res, next }); }); -router.delete('/template/:collection(measurement|treatment)/:name', (req, res, next) => { - if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; - - (req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel) - .findOneAndDelete({name: req.params.name}).lean().exec((err, data) => { - if (err) return next(err); - if (data) { - res.json({status: 'OK'}) - } - else { - res.status(404).json({status: 'Not found'}); - } - }); -}); +// router.delete('/template/:collection(measurement|treatment)/:name', (req, res, next) => { +// if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; +// +// (req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel) +// .findOneAndDelete({name: req.params.name}).lean().exec((err, data) => { +// if (err) return next(err); +// if (data) { +// res.json({status: 'OK'}) +// } +// else { +// res.status(404).json({status: 'Not found'}); +// } +// }); +// }); module.exports = router; \ No newline at end of file diff --git a/src/test/db.json b/src/test/db.json index 6ac2156..619fb75 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -308,6 +308,8 @@ { "_id": {"$oid":"200000000000000000000001"}, "name": "heat treatment", + "version": 1, + "number_prefix": "A", "parameters": [ { "name": "material", @@ -331,6 +333,8 @@ { "_id": {"$oid":"200000000000000000000002"}, "name": "heat treatment 2", + "version": 2, + "number_prefix": "B", "parameters": [ { "name": "material", @@ -344,6 +348,7 @@ { "_id": {"$oid":"300000000000000000000001"}, "name": "spectrum", + "version": 1, "parameters": [ { "name": "dpt", @@ -357,6 +362,7 @@ { "_id": {"$oid":"300000000000000000000002"}, "name": "kf", + "version": 2, "parameters": [ { "name": "weight %", @@ -378,6 +384,7 @@ { "_id": {"$oid":"300000000000000000000003"}, "name": "mt 3", + "version": 1, "parameters": [ { "name": "val1", From 81a7663f6c1fb5de3bcd76f27d23045e21beaf6b Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 14 May 2020 12:31:57 +0200 Subject: [PATCH 31/83] GET finished --- src/api.ts | 2 +- src/models/measurement_template.ts | 1 + src/models/treatment_template.ts | 2 + src/routes/template.spec.ts | 502 ++++++++++++++++++++--------- src/routes/template.ts | 34 +- src/routes/validate/template.ts | 69 +++- 6 files changed, 432 insertions(+), 178 deletions(-) diff --git a/src/api.ts b/src/api.ts index f85393f..228f166 100644 --- a/src/api.ts +++ b/src/api.ts @@ -25,7 +25,7 @@ export default class api { console.error(err); } else { - console.info('API ok, version ' + api.info.version); + console.info(process.env.NODE_ENV === 'test' ? '' : 'API ok, version ' + api.info.version); swagger.setup(apiDoc); } }); diff --git a/src/models/measurement_template.ts b/src/models/measurement_template.ts index c55cbc7..1b0f6e9 100644 --- a/src/models/measurement_template.ts +++ b/src/models/measurement_template.ts @@ -2,6 +2,7 @@ import mongoose from 'mongoose'; const MeasurementTemplateSchema = new mongoose.Schema({ name: {type: String, index: {unique: true}}, + version: Number, parameters: [{ name: String, range: mongoose.Schema.Types.Mixed diff --git a/src/models/treatment_template.ts b/src/models/treatment_template.ts index 3b61164..8fc4af8 100644 --- a/src/models/treatment_template.ts +++ b/src/models/treatment_template.ts @@ -2,6 +2,8 @@ import mongoose from 'mongoose'; const TreatmentTemplateSchema = new mongoose.Schema({ name: {type: String, index: {unique: true}}, + version: Number, + number_prefix: String, parameters: [{ name: String, range: mongoose.Schema.Types.Mixed diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index 3c05991..e89e238 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -24,7 +24,7 @@ describe('/template', () => { const json = require('../test/db.json'); should(res.body).have.lengthOf(json.collections.treatment_templates.length); should(res.body).matchEach(treatment => { - should(treatment).have.only.keys('_id', 'name', 'parameters'); + should(treatment).have.only.keys('_id', 'name', 'version', 'parameters', 'number_prefix'); should(treatment).have.property('_id').be.type('string'); should(treatment).have.property('name').be.type('string'); should(treatment).have.property('version').be.type('number'); @@ -131,22 +131,44 @@ describe('/template', () => { }).end((err, res) => { if (err) return done(err); should(res.body).be.eql({_id: '200000000000000000000001', name: 'heat aging', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {min: 1}}]}); - TemplateTreatmentModel.find({name: 'heat aging'}).lean().exec((err, data:any) => { + TemplateTreatmentModel.findById('200000000000000000000001').lean().exec((err, data:any) => { if (err) return done(err); - should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters', '__v'); - should(data[0]).have.property('name', 'heat aging'); - should(data[0]).have.property('version', 2); - should(data[0]).have.property('number_prefix', 'A'); - should(data[0]).have.property('parameters').have.lengthOf(1); - should(data[0].parameters[0]).have.property('name', 'time'); - should(data[0].parameters[0]).have.property('range'); - should(data[0].parameters[0].range).have.property('min', 1); + should(data).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters', '__v'); + should(data).have.property('name', 'heat aging'); + should(data).have.property('version', 2); + should(data).have.property('number_prefix', 'A'); + should(data).have.property('parameters').have.lengthOf(1); + should(data.parameters[0]).have.property('name', 'time'); + should(data.parameters[0]).have.property('range'); + should(data.parameters[0].range).have.property('min', 1); done(); }); }); }); - it('allows changing only one property'); // TODO: adapt PUT to other PUTs and do POST, everything for measurement too + it('allows changing only one property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '200000000000000000000001', name: 'heat aging', version: 2, number_prefix: 'A', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]}); + TemplateTreatmentModel.find({name: 'heat aging'}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters', '__v'); + should(data[0]).have.property('name', 'heat aging'); + should(data[0]).have.property('version', 2); + should(data[0]).have.property('number_prefix', 'A'); + should(data[0]).have.property('parameters').have.lengthOf(1); + should(data[0].parameters[0]).have.property('name', 'material'); + should(data[0].parameters[1]).have.property('name', 'weeks'); + done(); + }); + }); + }); it('supports values ranges', done => { TestHelper.request(server, done, { method: 'put', @@ -167,6 +189,16 @@ describe('/template', () => { res: {_id: '200000000000000000000001', name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {min: 1, max: 11}}]} }); }); + it('supports array type ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'time', range: {type: 'array'}}]}, + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {type: 'array'}}]} + }); + }); it('supports empty ranges', done => { TestHelper.request(server, done, { method: 'put', @@ -177,6 +209,16 @@ describe('/template', () => { res: {_id: '200000000000000000000001', name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {}}]} }); }); + it('rejects not specified parameters', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat treatment', parameters: [{name: 'material', range: {xx: 5}}]}, + res: {} + }); + }) it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'put', @@ -236,63 +278,81 @@ describe('/template', () => { describe('POST /template/treatment/new', () => { it('returns the right treatment template', done => { TestHelper.request(server, done, { - method: 'put', - url: '/template/treatment/200000000000000000000001', + method: 'post', + url: '/template/treatment/new', auth: {basic: 'admin'}, httpStatus: 200, - req: {}, - res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, number_prefix: 'A', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + req: {name: 'heat treatment3', number_prefix: 'C', parameters: [{name: 'material', range: {values: ['copper']}}]} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters'); + should(res.body).have.property('name', 'heat treatment3'); + should(res.body).have.property('version', 1); + should(res.body).have.property('number_prefix', 'C'); + should(res.body).have.property('parameters').have.lengthOf(1); + should(res.body.parameters[0]).have.property('name', 'material'); + should(res.body.parameters[0]).have.property('range'); + should(res.body.parameters[0].range).have.property('values'); + should(res.body.parameters[0].range.values[0]).be.eql('copper'); }); }); it('stores the template', done => { TestHelper.request(server, done, { - method: 'put', - url: '/template/treatment/200000000000000000000001', + method: 'post', + url: '/template/treatment/new', auth: {basic: 'admin'}, httpStatus: 200, - req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} + req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {min: 1}}]} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '200000000000000000000001', name: 'heat aging', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {min: 1}}]}); - TemplateTreatmentModel.find({name: 'heat aging'}).lean().exec((err, data:any) => { + TemplateTreatmentModel.findById(res.body._id).lean().exec((err, data:any) => { if (err) return done(err); - should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters', '__v'); - should(data[0]).have.property('name', 'heat aging'); - should(data[0]).have.property('version', 2); - should(data[0]).have.property('number_prefix', 'A'); - should(data[0]).have.property('parameters').have.lengthOf(1); - should(data[0].parameters[0]).have.property('name', 'time'); - should(data[0].parameters[0]).have.property('range'); - should(data[0].parameters[0].range).have.property('min', 1); + should(data).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters', '__v'); + should(data).have.property('name', 'heat aging'); + should(data).have.property('version', 1); + should(data).have.property('number_prefix', 'C'); + should(data).have.property('parameters').have.lengthOf(1); + should(data.parameters[0]).have.property('name', 'time'); + should(data.parameters[0]).have.property('range'); + should(data.parameters[0].range).have.property('min', 1); done(); }); }); }); it('rejects a missing name', done => { TestHelper.request(server, done, { - method: 'put', - url: '/template/treatment/heat%20aging', + method: 'post', + url: '/template/treatment/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {parameters: [{name: 'time', range: {min: 1}}]}, + req: {number_prefix: 'C', parameters: [{name: 'time', range: {min: 1}}]}, res: {status: 'Invalid body format', details: '"name" is required'} }); }); + it('rejects a missing number prefix', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/treatment/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}, + res: {status: 'Invalid body format', details: '"number_prefix" is required'} + }); + }); it('rejects missing parameters', done => { TestHelper.request(server, done, { - method: 'put', - url: '/template/treatment/heat%20aging', + method: 'post', + url: '/template/treatment/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {name: 'heat aging'}, + req: {name: 'heat aging', number_prefix: 'C'}, res: {status: 'Invalid body format', details: '"parameters" is required'} }); }); it('rejects a missing parameter name', done => { TestHelper.request(server, done, { - method: 'put', - url: '/template/treatment/heat%20aging', + method: 'post', + url: '/template/treatment/new', auth: {basic: 'admin'}, httpStatus: 400, req: {name: 'heat aging', parameters: [{range: {min: 1}}]}, @@ -301,18 +361,18 @@ describe('/template', () => { }); it('rejects a missing parameter range', done => { TestHelper.request(server, done, { - method: 'put', - url: '/template/treatment/heat%20aging', + method: 'post', + url: '/template/treatment/new', auth: {basic: 'admin'}, httpStatus: 400, req: {name: 'heat aging', parameters: [{name: 'time'}]}, res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} }); }); - it('rejects a an invalid parameter range property', done => { + it('rejects an invalid parameter range property', done => { TestHelper.request(server, done, { - method: 'put', - url: '/template/treatment/heat%20aging', + method: 'post', + url: '/template/treatment/new', auth: {basic: 'admin'}, httpStatus: 400, req: {name: 'heat aging', parameters: [{name: 'time', range: {xx: 1}}]}, @@ -321,12 +381,48 @@ describe('/template', () => { }); it('rejects wrong properties', done => { TestHelper.request(server, done, { - method: 'put', - url: '/template/treatment/heat%20aging', + method: 'post', + url: '/template/treatment/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {parameters: [{name: 'time'}], xx: 33}, - res: {status: 'Invalid body format', details: '"name" is required'} + req: {name: 'heat aging', parameters: [{name: 'time', range: {}}], xx: 33}, + res: {} + }); + }); + it('rejects already existing number prefixes', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/treatment/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', number_prefix: 'B', parameters: [{name: 'time', range: {min: 1}}]}, + res: {status: 'Number prefix already taken'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/treatment/new', + auth: {key: 'admin'}, + httpStatus: 401, + req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {min: 1}}]} + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/treatment/new', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {min: 1}}]} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/treatment/new', + httpStatus: 401, + req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {min: 1}}]} }); }); }); @@ -345,9 +441,10 @@ describe('/template', () => { const json = require('../test/db.json'); should(res.body).have.lengthOf(json.collections.measurement_templates.length); should(res.body).matchEach(measurement => { - should(measurement).have.only.keys('_id', 'name', 'parameters'); + should(measurement).have.only.keys('_id', 'name', 'version', 'parameters'); should(measurement).have.property('_id').be.type('string'); should(measurement).have.property('name').be.type('string'); + should(measurement).have.property('version').be.type('number'); should(measurement.parameters).matchEach(number => { should(number).have.only.keys('name', 'range'); should(number).have.property('name').be.type('string'); @@ -374,28 +471,28 @@ describe('/template', () => { }); }); - describe('GET /template/measurement/{name}', () => { + describe('GET /template/measurement/id', () => { it('returns the right measurement template', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/measurement/spectrum', + url: '/template/measurement/300000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {}}]} + res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: {}}]} }); }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/measurement/spectrum', + url: '/template/measurement/300000000000000000000001', auth: {key: 'janedoe'}, httpStatus: 401 }); }); - it('rejects an unknown name', done => { + it('rejects an unknown id', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/measurement/xxx', + url: '/template/measurement/000000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 404 }); @@ -403,7 +500,7 @@ describe('/template', () => { it('rejects unauthorized requests', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/measurement/spectrum', + url: '/template/measurement/300000000000000000000001', httpStatus: 401 }); }); @@ -413,38 +510,49 @@ describe('/template', () => { it('returns the right measurement template', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/spectrum', + url: '/template/measurement/300000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {}, - res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {}}]} + res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: {}}]} }); }); it('keeps unchanged properties', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/spectrum', + url: '/template/measurement/300000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {name: 'spectrum', parameters: [{name: 'dpt', range: {}}]}, - res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {}}]} + res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: {type: 'array'}}]} + }); + }); + it('keeps only one unchanged property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'spectrum'}, + res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: {type: 'array'}}]} }); }); it('changes the given properties', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/spectrum', + url: '/template/measurement/300000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, }).end((err, res) => { if (err) return done(err); should(res.body).be.eql({_id: '300000000000000000000001', name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}); - TemplateMeasurementModel.find({name: 'IR spectrum'}).lean().exec((err, data:any) => { + TemplateMeasurementModel.findById('300000000000000000000001').lean().exec((err, data:any) => { if (err) return done(err); should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'parameters', '__v'); + should(data[0]).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); should(data[0]).have.property('name', 'IR spectrum'); + should(data[0]).have.property('version', 2); should(data[0]).have.property('parameters').have.lengthOf(1); should(data[0].parameters[0]).have.property('name', 'data point table'); should(data[0].parameters[0]).have.property('range'); @@ -454,132 +562,102 @@ describe('/template', () => { }); }); }); + it('allows changing only one property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'IR spectrum'}, + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '300000000000000000000001', name: 'IR spectrum', parameters: [{name: 'dpt', range: {type: 'array'}}]}); + TemplateMeasurementModel.findById('300000000000000000000001').lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); + should(data).have.property('name', 'IR spectrum'); + should(data).have.property('version', 2); + should(data).have.property('parameters').have.lengthOf(1); + should(data.parameters[0]).have.property('name', 'dpt'); + should(data.parameters[0]).have.property('range'); + should(data.parameters[0].range).have.property('type', 'array'); + done(); + }); + }); + }); it('supports values ranges', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/spectrum', + url: '/template/measurement/300000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]}, - res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]} + res: {_id: '300000000000000000000001', name: 'spectrum', version: 2, parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]} }); }); it('supports min max ranges', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/spectrum', + url: '/template/measurement/300000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]}, - res: {_id: '300000000000000000000001', name: 'spectrum', parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]} + res: {_id: '300000000000000000000001', name: 'spectrum', version: 2, parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]} + }); + }); + it('supports min max ranges', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {parameters: [{name: 'dpt', range: {type: 'array'}}]}, + res: {_id: '300000000000000000000001', name: 'spectrum', version: 2, parameters: [{name: 'dpt', range: {type: 'array'}}]} }); }); it('supports empty ranges', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/kf', + url: '/template/measurement/300000000000000000000002', auth: {basic: 'admin'}, httpStatus: 200, req: {parameters: [{name: 'weight %', range: {}}]}, - res: {_id: '300000000000000000000002', name: 'kf', parameters: [{name: 'weight %', range: {}}]} + res: {_id: '300000000000000000000002', name: 'kf', version: 2, parameters: [{name: 'weight %', range: {}}]} }); }); - it('adds a new template for an unknown name', done => { + it('rejects not specified parameters', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/vz', - auth: {basic: 'admin'}, - httpStatus: 200, - req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]} - }).end(err => { - if (err) return done(err); - TemplateMeasurementModel.find({name: 'vz'}).lean().exec((err, data:any) => { - if (err) return done(err); - should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'parameters', '__v'); - should(data[0]).have.property('name', 'vz'); - should(data[0]).have.property('parameters').have.lengthOf(1); - should(data[0].parameters[0]).have.property('name', 'vz'); - should(data[0].parameters[0]).have.property('range'); - should(data[0].parameters[0].range).have.property('min', 1); - done(); - }); - }); - }); - it('rejects a missing name for a new name', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/template/measurement/spectrum2', + url: '/template/measurement/300000000000000000000001', auth: {basic: 'admin'}, httpStatus: 400, - req: {parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, - res: {status: 'Invalid body format', details: '"name" is required'} + req: {parameters: [{name: 'dpt'}], range: {xx: 33}}, + res: {} }); }); - it('rejects missing parameters for a new name', done => { + it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/spectrum2', + url: '/template/measurement/3000000000h0000000000001', auth: {basic: 'admin'}, - httpStatus: 400, - req: {name: 'IR spectrum'}, - res: {status: 'Invalid body format', details: '"parameters" is required'} + httpStatus: 404, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, }); }); - it('rejects a missing parameter name for a new name', done => { + it('rejects an unknown id', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/spectrum2', + url: '/template/measurement/000000000000000000000001', auth: {basic: 'admin'}, - httpStatus: 400, - req: {name: 'IR spectrum', parameters: [{range: {min: 0, max: 1000}}]}, - res: {status: 'Invalid body format', details: '"parameters[0].name" is required'} - }); - }); - it('rejects a missing parameter range for a new name', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/template/measurement/spectrum2', - auth: {basic: 'admin'}, - httpStatus: 400, - req: {name: 'IR spectrum', parameters: [{name: 'data point table'}]}, - res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} - }); - }); - it('rejects a an invalid parameter range property for a new name', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/template/measurement/spectrum2', - auth: {basic: 'admin'}, - httpStatus: 400, - req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {xx: 0}}]}, - res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} - }); - }); - it('rejects already existing names', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/template/measurement/spectrum', - auth: {basic: 'admin'}, - httpStatus: 400, - req: {name: 'kf', parameters: [{name: 'dpt', range: {min: 1}}]}, - res: {status: 'Template name already taken'} - }); - }); - it('rejects wrong properties', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/template/measurement/spectrum', - auth: {basic: 'admin'}, - httpStatus: 400, - req: {parameters: [{name: 'dpt'}], xx: 33}, - res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} + httpStatus: 404, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, }); }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/spectrum', + url: '/template/measurement/300000000000000000000001', auth: {key: 'admin'}, httpStatus: 401, req: {} @@ -588,7 +666,7 @@ describe('/template', () => { it('rejects requests from a write user', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/spectrum', + url: '/template/measurement/300000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 403, req: {} @@ -597,11 +675,141 @@ describe('/template', () => { it('rejects unauthorized requests', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/spectrum', + url: '/template/measurement/300000000000000000000001', httpStatus: 401, req: {} }); }); }); + + describe('POST /template/measurement/new', () => { + it('returns the right measurement template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'name', 'version', 'parameters'); + should(res.body).have.property('name', 'vz'); + should(res.body).have.property('version', 1); + should(res.body).have.property('parameters').have.lengthOf(1); + should(res.body.parameters[0]).have.property('name', 'vz'); + should(res.body.parameters[0]).have.property('range'); + should(res.body.parameters[0].range).have.property('min', 1); + }); + }); + it('stores the template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]} + }).end(err => { + if (err) return done(err); + TemplateMeasurementModel.find({name: 'vz'}).lean().exec((err, data:any) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); + should(data[0]).have.property('name', 'vz'); + should(data[0]).have.property('vaersion', 1); + should(data[0]).have.property('parameters').have.lengthOf(1); + should(data[0].parameters[0]).have.property('name', 'vz'); + should(data[0].parameters[0]).have.property('range'); + should(data[0].parameters[0].range).have.property('min', 1); + done(); + }); + }); + }); + it('rejects a missing name', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, + res: {status: 'Invalid body format', details: '"name" is required'} + }); + }); + it('rejects missing parameters', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum'}, + res: {status: 'Invalid body format', details: '"parameters" is required'} + }); + }); + it('rejects a missing parameter name', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{range: {min: 0, max: 1000}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].name" is required'} + }); + }); + it('rejects a missing parameter range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{name: 'data point table'}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} + }); + }); + it('rejects a an invalid parameter range property', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {xx: 0}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} + }); + }); + it('rejects wrong properties', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {}}], xx: 35}, + res: {} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {key: 'admin'}, + httpStatus: 401, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]} + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + httpStatus: 401, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]} + }); + }); + }); }); }); \ No newline at end of file diff --git a/src/routes/template.ts b/src/routes/template.ts index 55088f9..6c3212b 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -5,6 +5,7 @@ import TemplateValidate from './validate/template'; import TemplateTreatmentModel from '../models/treatment_template'; import TemplateMeasurementModel from '../models/measurement_template'; import res400 from './validate/res400'; +import IdValidate from './validate/id'; // TODO: remove f() for await @@ -13,21 +14,20 @@ const router = express.Router(); router.get('/template/:collection(measurements|treatments)', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; - (req.params.collection === 'treatments' ? TemplateTreatmentModel : TemplateMeasurementModel) - .find({}).lean().exec((err, data) => { + req.params.collection = req.params.collection.replace(/s$/g, ''); + model(req).find({}).lean().exec((err, data) => { if (err) next (err); - res.json(_.compact(data.map(e => TemplateValidate.output(e)))); // validate all and filter null values from validation errors + res.json(_.compact(data.map(e => TemplateValidate.output(e, req.params.collection)))); // validate all and filter null values from validation errors }); }); -router.get('/template/:collection(measurement|treatment)/:name', (req, res, next) => { +router.get('/template/:collection(measurement|treatment)/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; - (req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel) - .findOne({name: req.params.name}).lean().exec((err, data) => { + model(req).findById(req.params.id).lean().exec((err, data) => { if (err) next (err); if (data) { - res.json(TemplateValidate.output(data)); + res.json(TemplateValidate.output(data, req.params.collection)); } else { res.status(404).json({status: 'Not found'}); @@ -35,19 +35,18 @@ router.get('/template/:collection(measurement|treatment)/:name', (req, res, next }); }); -router.put('/template/:collection(measurement|treatment)/:name', (req, res, next) => { +router.put('/template/:collection(measurement|treatment)/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; - const collectionModel = req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel; - collectionModel.findOne({name: req.params.name}).lean().exec((err, data) => { + model(req).findOne({name: req.params.name}).lean().exec((err, data) => { if (err) next (err); const templateState = data? 'change': 'new'; - const {error, value: template} = TemplateValidate.input(req.body, templateState); + const {error, value: template} = TemplateValidate.input(req.body, templateState, req.params.collection); if (error) return res400(error, res); if (template.hasOwnProperty('name') && template.name !== req.params.name) { - collectionModel.find({name: template.name}).lean().exec((err, data) => { + model(req).find({name: template.name}).lean().exec((err, data) => { if (err) next (err); if (data.length > 0) { res.status(400).json({status: 'Template name already taken'}); @@ -63,9 +62,9 @@ router.put('/template/:collection(measurement|treatment)/:name', (req, res, next } function f() { // to resolve async - collectionModel.findOneAndUpdate({name: req.params.name}, template, {new: true, upsert: true}).lean().exec((err, data) => { + model(req).findOneAndUpdate({name: req.params.name}, template, {new: true, upsert: true}).lean().exec((err, data) => { if (err) return next(err); - res.json(TemplateValidate.output(data)); + res.json(TemplateValidate.output(data, req.params.collection)); }); } }); @@ -87,4 +86,9 @@ router.put('/template/:collection(measurement|treatment)/:name', (req, res, next // }); -module.exports = router; \ No newline at end of file +module.exports = router; + + +function model (req) { + return req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel; +} \ No newline at end of file diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts index a279dce..440c515 100644 --- a/src/routes/validate/template.ts +++ b/src/routes/validate/template.ts @@ -6,6 +6,13 @@ export default class TemplateValidate { name: joi.string() .max(128), + version: joi.number() + .min(1), + + number_prefix: joi.string() + .min(1) + .max(16), + parameters: joi.array() .min(1) .items( @@ -29,31 +36,63 @@ export default class TemplateValidate { ) }; - static input (data, param) { // validate data, param: new(everything required)/change(available attributes are validated) + static input (data, param, template) { // validate data, param: new(everything required)/change(available attributes are validated) if (param === 'new') { - return joi.object({ - name: this.template.name.required(), - parameters: this.template.parameters.required() - }).validate(data); + if (template === 'treatment') { + return joi.object({ + name: this.template.name.required(), + number_prefix: this.template.number_prefix.required(), + parameters: this.template.parameters.required() + }).validate(data); + } + else { + return joi.object({ + name: this.template.name.required(), + parameters: this.template.parameters.required() + }).validate(data); + } } else if (param === 'change') { - return joi.object({ - name: this.template.name, - parameters: this.template.parameters - }).validate(data); + if (template === 'treatment') { + return joi.object({ + name: this.template.name, + number_prefix: this.template.number_prefix, + parameters: this.template.parameters + }).validate(data); + } + else { + return joi.object({ + name: this.template.name, + parameters: this.template.parameters + }).validate(data); + } } else { return{error: 'No parameter specified!', value: {}}; } } - static output (data) { // validate output from database for needed properties, strip everything else + static output (data, template) { // validate output from database for needed properties, strip everything else data = IdValidate.stringify(data); - const {value, error} = joi.object({ - _id: IdValidate.get(), - name: this.template.name, - parameters: this.template.parameters - }).validate(data, {stripUnknown: true}); + let joiObject; + if (template === 'treatment') { + joiObject = { + _id: IdValidate.get(), + name: this.template.name, + version: this.template.version, + number_prefix: this.template.number_prefix, + parameters: this.template.parameters + }; + } + else { + joiObject = { + _id: IdValidate.get(), + name: this.template.name, + version: this.template.version, + parameters: this.template.parameters + }; + } + const {value, error} = joi.object(joiObject).validate(data, {stripUnknown: true}); return error !== undefined? null : value; } } \ No newline at end of file From 8315fc0d3ba63a07bec516b0733dd381089e4bc6 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 14 May 2020 15:36:47 +0200 Subject: [PATCH 32/83] PUT finished --- src/models/measurement_template.ts | 2 +- src/models/treatment_template.ts | 2 +- src/routes/condition.ts | 4 +- src/routes/material.ts | 4 +- src/routes/measurement.ts | 4 +- src/routes/sample.spec.ts | 26 +++++++ src/routes/sample.ts | 44 ++++++----- src/routes/template.spec.ts | 117 +++++++++++++++++------------ src/routes/template.ts | 58 +++++++------- src/routes/validate/template.ts | 8 +- src/routes/validate/user.ts | 2 +- 11 files changed, 162 insertions(+), 109 deletions(-) diff --git a/src/models/measurement_template.ts b/src/models/measurement_template.ts index 1b0f6e9..080f42b 100644 --- a/src/models/measurement_template.ts +++ b/src/models/measurement_template.ts @@ -1,7 +1,7 @@ import mongoose from 'mongoose'; const MeasurementTemplateSchema = new mongoose.Schema({ - name: {type: String, index: {unique: true}}, + name: String, version: Number, parameters: [{ name: String, diff --git a/src/models/treatment_template.ts b/src/models/treatment_template.ts index 8fc4af8..154ae79 100644 --- a/src/models/treatment_template.ts +++ b/src/models/treatment_template.ts @@ -1,7 +1,7 @@ import mongoose from 'mongoose'; const TreatmentTemplateSchema = new mongoose.Schema({ - name: {type: String, index: {unique: true}}, + name: String, version: Number, number_prefix: String, parameters: [{ diff --git a/src/routes/condition.ts b/src/routes/condition.ts index 8acc2f2..89ddce0 100644 --- a/src/routes/condition.ts +++ b/src/routes/condition.ts @@ -34,9 +34,7 @@ router.put('/condition/' + IdValidate.parameter(), async (req, res, next) => { if (error) return res400(error, res); const data = await ConditionModel.findById(req.params.id).lean().exec().catch(err => {next(err);}) as any; - if (data instanceof Error) { - return; - } + if (data instanceof Error) return; if (!data) { res.status(404).json({status: 'Not found'}); } diff --git a/src/routes/material.ts b/src/routes/material.ts index a912f5e..1c33591 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -104,9 +104,7 @@ module.exports = router; async function nameCheck (material, res, next) { // check if name was already taken const materialData = await MaterialModel.findOne({name: material.name}).lean().exec().catch(err => {next(err); return false;}) as any; - if (materialData instanceof Error) { - return false; - } + if (materialData instanceof Error) return false; if (materialData) { // could not find material_id res.status(400).json({status: 'Material name already taken'}); return false; diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index 4aa89c1..bb69b3f 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -32,9 +32,7 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { if (error) return res400(error, res); const data = await MeasurementModel.findById(req.params.id).lean().exec().catch(err => {next(err);}) as any; - if (data instanceof Error) { - return; - } + if (data instanceof Error) return; if (!data) { res.status(404).json({status: 'Not found'}); } diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 99f6344..42c8435 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -124,6 +124,32 @@ describe('/sample', () => { }); }); }); + it('keeps unchanged notes', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {notes: {comment: 'Stoff gesperrt', sample_references: []}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '400000000000000000000002', number: '21', type: 'granulate', color: 'natural', batch: '1560237365', material_id: '100000000000000000000001', note_id: '500000000000000000000001', user_id: '000000000000000000000002'}); + SampleModel.findById('400000000000000000000002').lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', 'status', '__v'); + should(data).have.property('_id'); + should(data).have.property('number', '21'); + should(data).have.property('color', 'natural'); + should(data).have.property('type', 'granulate'); + should(data).have.property('batch', '1560237365'); + should(data.material_id.toString()).be.eql('100000000000000000000001'); + should(data.user_id.toString()).be.eql('000000000000000000000002'); + should(data).have.property('status', 10); + should(data.note_id.toString()).be.eql('500000000000000000000001'); + done(); + }); + }); + }); it('changes the given properties', done => { TestHelper.request(server, done, { method: 'put', diff --git a/src/routes/sample.ts b/src/routes/sample.ts index a6e9ced..6acb7d2 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -46,25 +46,31 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!await materialCheck(sample, res, next, sampleData.material_id)) return; } - if (sample.hasOwnProperty('notes') && sampleData.note_id !== null) { // deal with old notes data - NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { - if (err) return console.error(err); - if (data.hasOwnProperty('custom_fields')) { // update note_fields - customFieldsChange(Object.keys(data.custom_fields), -1); + if (sample.hasOwnProperty('notes')) { + let newNotes = true; + if (sampleData.note_id !== null) { // old notes data exists + const data = await NoteModel.findById(sampleData.note_id).lean().exec().catch(err => {next(err);}) as any; + if (data instanceof Error) return; + newNotes = !_.isEqual(_.pick(IdValidate.stringify(data), _.keys(sample.notes)), sample.notes); + if (newNotes) { + if (data.hasOwnProperty('custom_fields')) { // update note_fields + customFieldsChange(Object.keys(data.custom_fields), -1); + } + NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec(err => { // delete old notes + if (err) return console.error(err); + }); } - NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec(err => { // delete old notes - if (err) return console.error(err); - }) - }); - } - if (sample.hasOwnProperty('notes') && Object.keys(sample.notes).length > 0) { // save new notes - if (!await sampleRefCheck(sample, res, next)) return; - if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields - customFieldsChange(Object.keys(sample.notes.custom_fields), 1); } - let data = await new NoteModel(sample.notes).save().catch(err => { return next(err)}); // save new notes - delete sample.notes; - sample.note_id = data._id; + + if (_.keys(sample.notes).length > 0 && newNotes) { // save new notes + if (!await sampleRefCheck(sample, res, next)) return; + if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields + customFieldsChange(Object.keys(sample.notes.custom_fields), 1); + } + let data = await new NoteModel(sample.notes).save().catch(err => { return next(err)}); // save new notes + delete sample.notes; + sample.note_id = data._id; + } } // check for changes @@ -160,9 +166,7 @@ async function numberCheck (sample, res, next) { // validate number, returns fa async function materialCheck (sample, res, next, id = sample.material_id) { // validate material_id and color, returns false if invalid const materialData = await MaterialModel.findById(id).lean().exec().catch(err => {next(err); return false;}) as any; - if (materialData instanceof Error) { - return false; - } + if (materialData instanceof Error) return false; if (!materialData) { // could not find material_id res.status(400).json({status: 'Material not available'}); return false; diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index e89e238..c42430b 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -1,4 +1,5 @@ import should from 'should/as-function'; +import _ from 'lodash'; import TemplateTreatmentModel from '../models/treatment_template'; import TemplateMeasurementModel from '../models/measurement_template'; import TestHelper from "../test/helper"; @@ -130,8 +131,7 @@ describe('/template', () => { req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '200000000000000000000001', name: 'heat aging', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {min: 1}}]}); - TemplateTreatmentModel.findById('200000000000000000000001').lean().exec((err, data:any) => { + TemplateTreatmentModel.findById(res.body._id).lean().exec((err, data:any) => { if (err) return done(err); should(data).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters', '__v'); should(data).have.property('name', 'heat aging'); @@ -154,17 +154,15 @@ describe('/template', () => { req: {name: 'heat aging'} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '200000000000000000000001', name: 'heat aging', version: 2, number_prefix: 'A', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]}); - TemplateTreatmentModel.find({name: 'heat aging'}).lean().exec((err, data:any) => { + TemplateTreatmentModel.findById(res.body._id).lean().exec((err, data:any) => { if (err) return done(err); - should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters', '__v'); - should(data[0]).have.property('name', 'heat aging'); - should(data[0]).have.property('version', 2); - should(data[0]).have.property('number_prefix', 'A'); - should(data[0]).have.property('parameters').have.lengthOf(1); - should(data[0].parameters[0]).have.property('name', 'material'); - should(data[0].parameters[1]).have.property('name', 'weeks'); + should(data).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters', '__v'); + should(data).have.property('name', 'heat aging'); + should(data).have.property('version', 2); + should(data).have.property('number_prefix', 'A'); + should(data).have.property('parameters').have.lengthOf(2); + should(data.parameters[0]).have.property('name', 'material'); + should(data.parameters[1]).have.property('name', 'weeks'); done(); }); }); @@ -175,8 +173,11 @@ describe('/template', () => { url: '/template/treatment/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, - req: {parameters: [{name: 'time', range: {values: [1, 2, 5]}}]}, - res: {_id: '200000000000000000000001', name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {values: [1, 2, 5]}}]} + req: {parameters: [{name: 'time', range: {values: [1, 2, 5]}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {values: [1, 2, 5]}}]}); + done(); }); }); it('supports min max ranges', done => { @@ -185,8 +186,11 @@ describe('/template', () => { url: '/template/treatment/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, - req: {parameters: [{name: 'time', range: {min: 1, max: 11}}]}, - res: {_id: '200000000000000000000001', name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {min: 1, max: 11}}]} + req: {parameters: [{name: 'time', range: {min: 1, max: 11}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {min: 1, max: 11}}]}); + done(); }); }); it('supports array type ranges', done => { @@ -195,8 +199,12 @@ describe('/template', () => { url: '/template/treatment/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, - req: {parameters: [{name: 'time', range: {type: 'array'}}]}, - res: {_id: '200000000000000000000001', name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {type: 'array'}}]} + req: {parameters: [{name: 'time', range: {type: 'array'}}]} + }).end((err, res) => { + console.log(res.body); + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {type: 'array'}}]}); + done(); }); }); it('supports empty ranges', done => { @@ -205,8 +213,11 @@ describe('/template', () => { url: '/template/treatment/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, - req: {parameters: [{name: 'time', range: {}}]}, - res: {_id: '200000000000000000000001', name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {}}]} + req: {parameters: [{name: 'time', range: {}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {}}]}); + done(); }); }); it('rejects not specified parameters', done => { @@ -216,7 +227,7 @@ describe('/template', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {name: 'heat treatment', parameters: [{name: 'material', range: {xx: 5}}]}, - res: {} + res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} }); }) it('rejects an invalid id', done => { @@ -478,7 +489,7 @@ describe('/template', () => { url: '/template/measurement/300000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: {}}]} + res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: { type: 'array'}}]} }); }); it('rejects an API key', done => { @@ -514,7 +525,7 @@ describe('/template', () => { auth: {basic: 'admin'}, httpStatus: 200, req: {}, - res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: {}}]} + res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: { type: 'array'}}]} }); }); it('keeps unchanged properties', done => { @@ -523,7 +534,7 @@ describe('/template', () => { url: '/template/measurement/300000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, - req: {name: 'spectrum', parameters: [{name: 'dpt', range: {}}]}, + req: {name: 'spectrum', parameters: [{name: 'dpt', range: { type: 'array'}}]}, res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: {type: 'array'}}]} }); }); @@ -546,18 +557,17 @@ describe('/template', () => { req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '300000000000000000000001', name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}); - TemplateMeasurementModel.findById('300000000000000000000001').lean().exec((err, data:any) => { + should(_.omit(res.body, '_id')).be.eql({name: 'IR spectrum', version: 2, parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}); + TemplateMeasurementModel.findById(res.body._id).lean().exec((err, data:any) => { if (err) return done(err); - should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); - should(data[0]).have.property('name', 'IR spectrum'); - should(data[0]).have.property('version', 2); - should(data[0]).have.property('parameters').have.lengthOf(1); - should(data[0].parameters[0]).have.property('name', 'data point table'); - should(data[0].parameters[0]).have.property('range'); - should(data[0].parameters[0].range).have.property('min', 0); - should(data[0].parameters[0].range).have.property('max', 1000); + should(data).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); + should(data).have.property('name', 'IR spectrum'); + should(data).have.property('version', 2); + should(data).have.property('parameters').have.lengthOf(1); + should(data.parameters[0]).have.property('name', 'data point table'); + should(data.parameters[0]).have.property('range'); + should(data.parameters[0].range).have.property('min', 0); + should(data.parameters[0].range).have.property('max', 1000); done(); }); }); @@ -571,10 +581,9 @@ describe('/template', () => { req: {name: 'IR spectrum'}, }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '300000000000000000000001', name: 'IR spectrum', parameters: [{name: 'dpt', range: {type: 'array'}}]}); - TemplateMeasurementModel.findById('300000000000000000000001').lean().exec((err, data:any) => { + should(_.omit(res.body, '_id')).be.eql({name: 'IR spectrum', version: 2, parameters: [{name: 'dpt', range: {type: 'array'}}]}); + TemplateMeasurementModel.findById(res.body._id).lean().exec((err, data:any) => { if (err) return done(err); - should(data).have.lengthOf(1); should(data).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); should(data).have.property('name', 'IR spectrum'); should(data).have.property('version', 2); @@ -592,8 +601,11 @@ describe('/template', () => { url: '/template/measurement/300000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, - req: {parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]}, - res: {_id: '300000000000000000000001', name: 'spectrum', version: 2, parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]} + req: {parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'spectrum', version: 2, parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]}); + done(); }); }); it('supports min max ranges', done => { @@ -602,18 +614,24 @@ describe('/template', () => { url: '/template/measurement/300000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, - req: {parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]}, - res: {_id: '300000000000000000000001', name: 'spectrum', version: 2, parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]} + req: {parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'spectrum', version: 2, parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]}); + done(); }); }); - it('supports min max ranges', done => { + it('supports array type ranges', done => { TestHelper.request(server, done, { method: 'put', url: '/template/measurement/300000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, - req: {parameters: [{name: 'dpt', range: {type: 'array'}}]}, - res: {_id: '300000000000000000000001', name: 'spectrum', version: 2, parameters: [{name: 'dpt', range: {type: 'array'}}]} + req: {parameters: [{name: 'dpt2', range: {type: 'array'}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'spectrum', version: 2, parameters: [{name: 'dpt2', range: {type: 'array'}}]}); + done(); }); }); it('supports empty ranges', done => { @@ -622,8 +640,11 @@ describe('/template', () => { url: '/template/measurement/300000000000000000000002', auth: {basic: 'admin'}, httpStatus: 200, - req: {parameters: [{name: 'weight %', range: {}}]}, - res: {_id: '300000000000000000000002', name: 'kf', version: 2, parameters: [{name: 'weight %', range: {}}]} + req: {parameters: [{name: 'weight %', range: {}}]} + }).end((err, res) => { + if (err) return done(err); + should(_.omit(res.body, '_id')).be.eql({name: 'kf', version: 3, parameters: [{name: 'weight %', range: {}}]}); + done(); }); }); it('rejects not specified parameters', done => { @@ -633,7 +654,7 @@ describe('/template', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {parameters: [{name: 'dpt'}], range: {xx: 33}}, - res: {} + res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} }); }); it('rejects an invalid id', done => { diff --git a/src/routes/template.ts b/src/routes/template.ts index 6c3212b..dac7a01 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -35,39 +35,32 @@ router.get('/template/:collection(measurement|treatment)/' + IdValidate.paramete }); }); -router.put('/template/:collection(measurement|treatment)/' + IdValidate.parameter(), (req, res, next) => { +router.put('/template/:collection(measurement|treatment)/' + IdValidate.parameter(), async (req, res, next) => { if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + const {error, value: template} = TemplateValidate.input(req.body, 'change', req.params.collection); + if (error) return res400(error, res); - model(req).findOne({name: req.params.name}).lean().exec((err, data) => { - if (err) next (err); - const templateState = data? 'change': 'new'; - const {error, value: template} = TemplateValidate.input(req.body, templateState, req.params.collection); - if (error) return res400(error, res); + const templateData = await model(req).findById(req.params.id).lean().exec().catch(err => {next(err);}) as any; + if (templateData instanceof Error) return; + if (!templateData) { + res.status(404).json({status: 'Not found'}); + } - if (template.hasOwnProperty('name') && template.name !== req.params.name) { - model(req).find({name: template.name}).lean().exec((err, data) => { - if (err) next (err); - if (data.length > 0) { - res.status(400).json({status: 'Template name already taken'}); - return; - } - else { - f(); - } - }); - } - else { - f(); - } + if (_.has(template, 'number_prefix') && template.number_prefix !== templateData.number_prefix) { // got new number_prefix + if (!await numberPrefixCheck(template, req, res, next)) return; + } - function f() { // to resolve async - model(req).findOneAndUpdate({name: req.params.name}, template, {new: true, upsert: true}).lean().exec((err, data) => { - if (err) return next(err); - res.json(TemplateValidate.output(data, req.params.collection)); - }); - } - }); + if (!_.isEqual(_.pick(templateData, _.keys(template)), template)) { // data was changed + template.version = templateData.version + 1; + await new (model(req))(_.assign({}, _.omit(templateData, ['_id', '__v']), template)).save((err, data) => { + if (err) next (err); + res.json(TemplateValidate.output(data.toObject(), req.params.collection)); + }); + } + else { + res.json(TemplateValidate.output(templateData, req.params.collection)); + } }); // router.delete('/template/:collection(measurement|treatment)/:name', (req, res, next) => { @@ -89,6 +82,15 @@ router.put('/template/:collection(measurement|treatment)/' + IdValidate.paramete module.exports = router; +async function numberPrefixCheck (template, req, res, next) { + const data = await model(req).findOne({number_prefix: template.number_prefix}).lean().exec().catch(err => {next(err); return false;}) as any; + if (data) { + res.status(400).json({status: 'Number prefix already taken'}); + return false + } + return true; +} + function model (req) { return req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel; } \ No newline at end of file diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts index 440c515..7cb461d 100644 --- a/src/routes/validate/template.ts +++ b/src/routes/validate/template.ts @@ -27,10 +27,16 @@ export default class TemplateValidate { min: joi.number(), - max: joi.number() + max: joi.number(), + + type: joi.string() + .valid('array') }) .oxor('values', 'min') .oxor('values', 'max') + .oxor('type', 'values') + .oxor('type', 'min') + .oxor('type', 'max') .required() }) ) diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index 024d1a9..4472aa8 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -5,7 +5,7 @@ import IdValidate from './id'; export default class UserValidate { // validate input for user private static user = { - name: Joi.string() + name: Joi.string() // TODO: check allowed characters .alphanum() .lowercase() .max(128), From ab8c74a6410b29bca1b66369ac39b0a33fbfd94f Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 14 May 2020 16:42:47 +0200 Subject: [PATCH 33/83] POST finished --- src/routes/template.spec.ts | 28 ++++++++++++++++++++-------- src/routes/template.ts | 30 ++++++++++++++++-------------- 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index c42430b..d9673b7 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -4,7 +4,7 @@ import TemplateTreatmentModel from '../models/treatment_template'; import TemplateMeasurementModel from '../models/measurement_template'; import TestHelper from "../test/helper"; -// TODO: convert name to id, criteria for new name, criteria for new version, criteria for prefix + describe('/template', () => { let server; @@ -305,6 +305,7 @@ describe('/template', () => { should(res.body.parameters[0]).have.property('range'); should(res.body.parameters[0].range).have.property('values'); should(res.body.parameters[0].range.values[0]).be.eql('copper'); + done(); }); }); it('stores the template', done => { @@ -366,17 +367,27 @@ describe('/template', () => { url: '/template/treatment/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {name: 'heat aging', parameters: [{range: {min: 1}}]}, + req: {name: 'heat aging', number_prefix: 'C', parameters: [{range: {min: 1}}]}, res: {status: 'Invalid body format', details: '"parameters[0].name" is required'} }); }); + it('rejects a missing number prefix', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/treatment/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}, + res: {status: 'Invalid body format', details: '"number_prefix" is required'} + }); + }); it('rejects a missing parameter range', done => { TestHelper.request(server, done, { method: 'post', url: '/template/treatment/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {name: 'heat aging', parameters: [{name: 'time'}]}, + req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time'}]}, res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} }); }); @@ -386,7 +397,7 @@ describe('/template', () => { url: '/template/treatment/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {name: 'heat aging', parameters: [{name: 'time', range: {xx: 1}}]}, + req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {xx: 1}}]}, res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} }); }); @@ -396,8 +407,8 @@ describe('/template', () => { url: '/template/treatment/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {name: 'heat aging', parameters: [{name: 'time', range: {}}], xx: 33}, - res: {} + req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {}}], xx: 33}, + res: {status: 'Invalid body format', details: '"xx" is not allowed'} }); }); it('rejects already existing number prefixes', done => { @@ -720,6 +731,7 @@ describe('/template', () => { should(res.body.parameters[0]).have.property('name', 'vz'); should(res.body.parameters[0]).have.property('range'); should(res.body.parameters[0].range).have.property('min', 1); + done(); }); }); it('stores the template', done => { @@ -736,7 +748,7 @@ describe('/template', () => { should(data).have.lengthOf(1); should(data[0]).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); should(data[0]).have.property('name', 'vz'); - should(data[0]).have.property('vaersion', 1); + should(data[0]).have.property('version', 1); should(data[0]).have.property('parameters').have.lengthOf(1); should(data[0].parameters[0]).have.property('name', 'vz'); should(data[0].parameters[0]).have.property('range'); @@ -802,7 +814,7 @@ describe('/template', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {}}], xx: 35}, - res: {} + res: {status: 'Invalid body format', details: '"xx" is not allowed'} }); }); it('rejects an API key', done => { diff --git a/src/routes/template.ts b/src/routes/template.ts index dac7a01..3997944 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -63,20 +63,22 @@ router.put('/template/:collection(measurement|treatment)/' + IdValidate.paramete } }); -// router.delete('/template/:collection(measurement|treatment)/:name', (req, res, next) => { -// if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; -// -// (req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel) -// .findOneAndDelete({name: req.params.name}).lean().exec((err, data) => { -// if (err) return next(err); -// if (data) { -// res.json({status: 'OK'}) -// } -// else { -// res.status(404).json({status: 'Not found'}); -// } -// }); -// }); +router.post('/template/:collection(measurement|treatment)/new', async (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + const {error, value: template} = TemplateValidate.input(req.body, 'new', req.params.collection); + if (error) return res400(error, res); + + if (_.has(template, 'number_prefix')) { // got number_prefix + if (!await numberPrefixCheck(template, req, res, next)) return; + } + + template.version = 1; + await new (model(req))(template).save((err, data) => { + if (err) next (err); + res.json(TemplateValidate.output(data.toObject(), req.params.collection)); + }); +}); module.exports = router; From c137a0f61046f15713fa907f4332ed1dc123dad4 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 14 May 2020 16:54:58 +0200 Subject: [PATCH 34/83] changed allowed characters for username --- src/routes/validate/user.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index 4472aa8..c146d7e 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -5,9 +5,9 @@ import IdValidate from './id'; export default class UserValidate { // validate input for user private static user = { - name: Joi.string() // TODO: check allowed characters - .alphanum() + name: Joi.string() .lowercase() + .pattern(new RegExp('^[a-z0-9-_.]+$')) .max(128), email: Joi.string() From abf0a99d8a872931bc968d8995e5e7714ac9eddc Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 15 May 2020 11:16:17 +0200 Subject: [PATCH 35/83] number generation for condition done --- api/condition.yaml | 5 +- api/material.yaml | 2 +- api/measurement.yaml | 2 +- api/sample.yaml | 2 +- api/schemas.yaml | 1 + src/routes/condition.spec.ts | 121 ++++++++++++++++--------------- src/routes/condition.ts | 34 +++++---- src/routes/sample.spec.ts | 1 + src/routes/template.spec.ts | 2 +- src/routes/template.ts | 2 +- src/routes/validate/condition.ts | 2 - src/test/db.json | 8 +- 12 files changed, 96 insertions(+), 86 deletions(-) diff --git a/api/condition.yaml b/api/condition.yaml index f924707..ec8b245 100644 --- a/api/condition.yaml +++ b/api/condition.yaml @@ -4,7 +4,7 @@ get: summary: condition by id description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: status handling (accessible (only for maintain/admin))? # TODO + x-doc: status handling (accessible (only for maintain/admin))? # TODO after decision tags: - /condition responses: @@ -38,9 +38,6 @@ allOf: - $ref: 'api.yaml#/components/schemas/_Id' properties: - number: - type: string - example: B1 parameters: type: object responses: diff --git a/api/material.yaml b/api/material.yaml index 6a86b38..9775b29 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -25,7 +25,7 @@ get: summary: get material details description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: status handling (accessible (only for maintain/admin))? # TODO + x-doc: status handling (accessible (only for maintain/admin))? # TODO after decision tags: - /material responses: diff --git a/api/measurement.yaml b/api/measurement.yaml index 2882883..298b04e 100644 --- a/api/measurement.yaml +++ b/api/measurement.yaml @@ -4,7 +4,7 @@ get: summary: measurement values by id description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: status handling (accessible (only for maintain/admin))? # TODO + x-doc: status handling (accessible (only for maintain/admin))? # TODO after decision tags: - /measurement responses: diff --git a/api/sample.yaml b/api/sample.yaml index 9539053..30500b4 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -24,7 +24,7 @@ get: summary: TODO sample details description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: status handling (accessible (only for maintain/admin))? # TODO + x-doc: status handling (accessible (only for maintain/admin))? # TODO after decision tags: - /sample responses: diff --git a/api/schemas.yaml b/api/schemas.yaml index 3f5098c..7141cbb 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -122,6 +122,7 @@ Condition: $ref: 'api.yaml#/components/schemas/Id' number: type: string + readOnly: true example: B1 parameters: type: object diff --git a/src/routes/condition.spec.ts b/src/routes/condition.spec.ts index 2967108..5881e4b 100644 --- a/src/routes/condition.spec.ts +++ b/src/routes/condition.spec.ts @@ -16,7 +16,7 @@ describe('/condition', () => { url: '/condition/700000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', parameters: {material: 'copper', weeks: 3}, treatment_template: '200000000000000000000001'} + res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', parameters: {material: 'copper', weeks: 3}, treatment_template: '200000000000000000000001'} }); }); it('returns the right condition for an API key', done => { @@ -25,7 +25,7 @@ describe('/condition', () => { url: '/condition/700000000000000000000001', auth: {key: 'janedoe'}, httpStatus: 200, - res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', parameters: {material: 'copper', weeks: 3}, treatment_template: '200000000000000000000001'} + res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', parameters: {material: 'copper', weeks: 3}, treatment_template: '200000000000000000000001'} }); }); it('rejects an invalid id', done => { @@ -61,7 +61,7 @@ describe('/condition', () => { auth: {basic: 'janedoe'}, httpStatus: 200, req: {}, - res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}} + res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}} }); }); it('keeps unchanged properties', done => { @@ -73,7 +73,7 @@ describe('/condition', () => { req: {parameters: {material: 'copper', weeks: 3}} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}}); + should(res.body).be.eql({_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}}); ConditionModel.findById('700000000000000000000001').lean().exec((err, data) => { if (err) return done(err); should(data).have.property('status', 10); @@ -90,7 +90,7 @@ describe('/condition', () => { req: {parameters: {material: 'copper'}} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}}); + should(res.body).be.eql({_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}}); ConditionModel.findById('700000000000000000000001').lean().exec((err, data) => { if (err) return done(err); should(data).have.property('status', 10); @@ -107,12 +107,12 @@ describe('/condition', () => { req: {parameters: {material: 'hot air', weeks: 10}} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'hot air', weeks: 10}}); + should(res.body).be.eql({_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', treatment_template: '200000000000000000000001', parameters: {material: 'hot air', weeks: 10}}); ConditionModel.findById('700000000000000000000001').lean().exec((err, data: any) => { if (err) return done(err); should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', 'status', '__v'); should(data.sample_id.toString()).be.eql('400000000000000000000001'); - should(data).have.property('number', 'B1'); + should(data).have.property('number', 'A1'); should(data.treatment_template.toString()).be.eql('200000000000000000000001'); should(data).have.property('status', 0); should(data).have.property('parameters'); @@ -129,7 +129,17 @@ describe('/condition', () => { auth: {basic: 'janedoe'}, httpStatus: 200, req: {parameters: {weeks: 8}}, - res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 8}} + res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 8}} + }); + }); + it('rejects changing the condition number', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/condition/700000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: 'C2'}, + res: {status: 'Invalid body format', details: '"number" is not allowed'} }); }); it('rejects not specified parameters', done => { @@ -198,7 +208,7 @@ describe('/condition', () => { auth: {basic: 'admin'}, httpStatus: 200, req: {parameters: {material: 'hot air', weeks: 10}}, - res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', treatment_template: '200000000000000000000001', parameters: {material: 'hot air', weeks: 10}} + res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', treatment_template: '200000000000000000000001', parameters: {material: 'hot air', weeks: 10}} }); }); it('rejects an API key', done => { @@ -227,34 +237,41 @@ describe('/condition', () => { req: {parameters: {material: 'hot air', weeks: 10}} }); }); - }); // TODO: how to deal with template changes? Template versioning? - // TODO: rewrite delete methods -> set status for every database collection + }); describe('DELETE /condition/{id}', () => { it('sets the status to deleted', done => { TestHelper.request(server, done, { method: 'delete', - url: '/condition/700000000000000000000002', + url: '/condition/700000000000000000000004', auth: {basic: 'janedoe'}, httpStatus: 200 }).end((err, res) => { if (err) return done(err); should(res.body).be.eql({status: 'OK'}); - ConditionModel.findById('700000000000000000000002').lean().exec((err, data: any) => { + ConditionModel.findById('700000000000000000000004').lean().exec((err, data: any) => { if (err) return done(err); should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', 'status', '__v'); - should(data.sample_id.toString()).be.eql('400000000000000000000002'); - should(data).have.property('number', 'B1'); + should(data.sample_id.toString()).be.eql('400000000000000000000001'); + should(data).have.property('number', 'A6'); should(data.treatment_template.toString()).be.eql('200000000000000000000001'); should(data).have.property('status', -1); should(data).have.property('parameters'); - should(data.parameters).have.property('material', 'copper'); - should(data.parameters).have.property('weeks', 3); + should(data.parameters).have.property('material', 'hot air'); + should(data.parameters).have.property('weeks', 5); done(); }); }); }); - it('rejects a deleting a condition referenced by measurements'); // TODO + it('rejects deleting a condition referenced by measurements'/*, done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/condition/700000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {status: 'Condition still in use'} + }); + }*/); // TODO after decision it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'delete', @@ -266,7 +283,7 @@ describe('/condition', () => { it('rejects an API key', done => { TestHelper.request(server, done, { method: 'delete', - url: '/condition/700000000000000000000002', + url: '/condition/700000000000000000000004', auth: {key: 'janedoe'}, httpStatus: 401 }); @@ -274,7 +291,7 @@ describe('/condition', () => { it('rejects requests from a read user', done => { TestHelper.request(server, done, { method: 'delete', - url: '/condition/700000000000000000000002', + url: '/condition/700000000000000000000004', auth: {basic: 'user'}, httpStatus: 403 }); @@ -290,7 +307,7 @@ describe('/condition', () => { it('accepts an maintain/admin user deleting a condition belonging to a sample of another user', done => { TestHelper.request(server, done, { method: 'delete', - url: '/condition/700000000000000000000002', + url: '/condition/700000000000000000000004', auth: {basic: 'admin'}, httpStatus: 200 }).end((err, res) => { @@ -302,7 +319,7 @@ describe('/condition', () => { it('returns 404 for an unknown id', done => { TestHelper.request(server, done, { method: 'delete', - url: '/condition/00000000000w000000000002', + url: '/condition/000000000000000000000002', auth: {basic: 'janedoe'}, httpStatus: 404 }); @@ -310,7 +327,7 @@ describe('/condition', () => { it('rejects unauthorized requests', done => { TestHelper.request(server, done, { method: 'delete', - url: '/condition/700000000000000000000002', + url: '/condition/700000000000000000000004', httpStatus: 401 }); }); @@ -323,13 +340,13 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} }).end((err, res) => { if (err) return done(err); should(res.body).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template'); should(res.body).have.property('_id').be.type('string'); should(res.body).have.property('sample_id', '400000000000000000000002'); - should(res.body).have.property('number', 'B2'); + should(res.body).have.property('number', 'A7'); should(res.body).have.property('treatment_template', '200000000000000000000001'); should(res.body).have.property('parameters'); should(res.body.parameters).have.property('material', 'hot air'); @@ -343,14 +360,14 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} }).end((err, res) => { if (err) return done(err); ConditionModel.findById(res.body._id).lean().exec((err, data: any) => { if (err) return done(err); should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', 'status', '__v'); should(data.sample_id.toString()).be.eql('400000000000000000000002'); - should(data).have.property('number', 'B2'); + should(data).have.property('number', 'A7'); should(data.treatment_template.toString()).be.eql('200000000000000000000001'); should(data).have.property('status', 0); should(data).have.property('parameters'); @@ -366,7 +383,7 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {sample_id: '4000000000h0000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + req: {sample_id: '4000000000h0000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, res: {status: 'Invalid body format', details: '"sample_id" with value "4000000000h0000000000002" fails to match the required pattern: /[0-9a-f]{24}/'} }); }); @@ -376,7 +393,7 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {sample_id: '000000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + req: {sample_id: '000000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, res: {status: 'Sample id not available'} }); }); @@ -386,7 +403,7 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000h00000000001'}, + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000h00000000001'}, res: {status: 'Invalid body format', details: '"treatment_template" with value "200000000000h00000000001" fails to match the required pattern: /[0-9a-f]{24}/'} }); }); @@ -396,18 +413,18 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '000000000000000000000001'}, + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '000000000000000000000001'}, res: {status: 'Treatment template not available'} }); }); - it('rejects a condition number already in use for this sample', done => { + it('rejects setting a condition number', done => { TestHelper.request(server, done, { method: 'post', url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {sample_id: '400000000000000000000001', number: 'B1', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, - res: {status: 'Condition number already taken'} + req: {sample_id: '400000000000000000000001', number: 'A7', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"number" is not allowed'} }); }); it('rejects not specified parameters', done => { @@ -416,7 +433,7 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10, xx: 12}, treatment_template: '200000000000000000000001'}, + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10, xx: 12}, treatment_template: '200000000000000000000001'}, res: {status: 'Invalid body format', details: '"xx" is not allowed'} }); }); @@ -426,7 +443,7 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air'}, treatment_template: '200000000000000000000001'}, + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air'}, treatment_template: '200000000000000000000001'}, res: {status: 'Invalid body format', details: '"weeks" is required'} }); }); @@ -436,7 +453,7 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'xxx', weeks: 10}, treatment_template: '200000000000000000000001'}, + req: {sample_id: '400000000000000000000002', parameters: {material: 'xxx', weeks: 10}, treatment_template: '200000000000000000000001'}, res: {status: 'Invalid body format', details: '"material" must be one of [copper, hot air]'} }); }); @@ -446,7 +463,7 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: -10}, treatment_template: '200000000000000000000001'}, + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: -10}, treatment_template: '200000000000000000000001'}, res: {status: 'Invalid body format', details: '"weeks" must be larger than or equal to 1'} }); }); @@ -456,7 +473,7 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 11}, treatment_template: '200000000000000000000001'}, + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 11}, treatment_template: '200000000000000000000001'}, res: {status: 'Invalid body format', details: '"weeks" must be less than or equal to 10'} }); }); @@ -466,7 +483,7 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + req: {parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, res: {status: 'Invalid body format', details: '"sample_id" is required'} }); }); @@ -476,27 +493,17 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}}, + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}}, res: {status: 'Invalid body format', details: '"treatment_template" is required'} }); }); - it('rejects a missing number', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, - res: {status: 'Invalid body format', details: '"number" is required'} - }); - }); it('rejects adding a condition to the sample of an other user for a write user', done => { TestHelper.request(server, done, { method: 'post', url: '/condition/new', auth: {basic: 'janedoe'}, httpStatus: 403, - req: {sample_id: '400000000000000000000003', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + req: {sample_id: '400000000000000000000003', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} }); }); it('accepts adding a condition to the sample of an other user for a maintain/admin user', done => { @@ -505,13 +512,13 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'admin'}, httpStatus: 200, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} }).end((err, res) => { if (err) return done(err); should(res.body).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template'); should(res.body).have.property('_id').be.type('string'); should(res.body).have.property('sample_id', '400000000000000000000002'); - should(res.body).have.property('number', 'B2'); + should(res.body).have.property('number', 'A7'); should(res.body).have.property('treatment_template', '200000000000000000000001'); should(res.body).have.property('parameters'); should(res.body.parameters).have.property('material', 'hot air'); @@ -525,7 +532,7 @@ describe('/condition', () => { url: '/condition/new', auth: {key: 'janedoe'}, httpStatus: 401, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} }); }); it('rejects requests from a read user', done => { @@ -534,7 +541,7 @@ describe('/condition', () => { url: '/condition/new', auth: {basic: 'user'}, httpStatus: 403, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} }); }); it('rejects unauthorized requests', done => { @@ -542,7 +549,7 @@ describe('/condition', () => { method: 'post', url: '/condition/new', httpStatus: 401, - req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} }); }); }); diff --git a/src/routes/condition.ts b/src/routes/condition.ts index 89ddce0..1e54a00 100644 --- a/src/routes/condition.ts +++ b/src/routes/condition.ts @@ -1,5 +1,4 @@ import express from 'express'; -import mongoose from 'mongoose'; import _ from 'lodash'; import ConditionValidate from './validate/condition'; @@ -79,9 +78,11 @@ router.post('/condition/new', async (req, res, next) => { if (error) return res400(error, res); if (!await sampleIdCheck(condition, req, res, next)) return; - if (!await numberCheck(condition, res, next)) return; - if (!await treatmentCheck(condition, 'new', res, next)) return; + const treatmentData = await treatmentCheck(condition, 'new', res, next) + if (!treatmentData) return; + condition.number = await numberGenerate(condition, treatmentData, next); + if (!condition.number) return; condition.status = 0; await new ConditionModel(condition).save((err, data) => { if (err) return next(err); @@ -104,24 +105,29 @@ async function sampleIdCheck (condition, req, res, next) { // validate sample_i return true; } -async function numberCheck (condition, res, next) { // validate number, returns false if invalid - const data = await ConditionModel.find({sample_id: new mongoose.Types.ObjectId(condition.sample_id), number: condition.number}).lean().exec().catch(err => {next(err); return false;}) as any; - if (data.length) { - res.status(400).json({status: 'Condition number already taken'}); - return false; - } - return true; +async function numberGenerate (condition, treatmentData, next) { // validate number, returns false if invalid + const conditionData = await ConditionModel + .find({number: new RegExp('^' + treatmentData.number_prefix + '[0-9]+$', 'm')}) + .sort({number: -1}) + .limit(1) + .lean() + .exec() + .catch(err => next(err)) as any; + if (conditionData instanceof Error) return false; + console.log(conditionData); + return treatmentData.number_prefix + (Number(conditionData[0].number.replace(/[^0-9]+/g, '')) + 1); } -async function treatmentCheck (condition, param, res, next) { - const treatmentData = await TreatmentTemplateModel.findById(condition.treatment_template).lean().exec().catch(err => {next(err); return false;}) as any; +async function treatmentCheck (condition, param, res, next) { // validate treatment template, returns false if invalid, otherwise template data + const treatmentData = await TreatmentTemplateModel.findById(condition.treatment_template).lean().exec().catch(err => next(err)) as any; + if (treatmentData instanceof Error) return false; if (!treatmentData) { // template not found res.status(400).json({status: 'Treatment template not available'}); - return false + return false; } // validate parameters const {error, value: ignore} = ParametersValidate.input(condition.parameters, treatmentData.parameters, param); if (error) {res400(error, res); return false;} - return true; + return treatmentData; } \ No newline at end of file diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 42c8435..8efce68 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -490,6 +490,7 @@ describe('/sample', () => { httpStatus: 404 }); }); + it('rejects deleting a sample referenced by conditions'); // TODO after decision it('rejects requests from a read user', done => { TestHelper.request(server, done, { method: 'delete', diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index d9673b7..68b99e9 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -4,7 +4,7 @@ import TemplateTreatmentModel from '../models/treatment_template'; import TemplateMeasurementModel from '../models/measurement_template'; import TestHelper from "../test/helper"; - +// TODO: check number prefix to have no numbers describe('/template', () => { let server; diff --git a/src/routes/template.ts b/src/routes/template.ts index 3997944..28caf2b 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -7,7 +7,7 @@ import TemplateMeasurementModel from '../models/measurement_template'; import res400 from './validate/res400'; import IdValidate from './validate/id'; -// TODO: remove f() for await + const router = express.Router(); diff --git a/src/routes/validate/condition.ts b/src/routes/validate/condition.ts index f130076..491c318 100644 --- a/src/routes/validate/condition.ts +++ b/src/routes/validate/condition.ts @@ -22,14 +22,12 @@ export default class ConditionValidate { if (param === 'new') { return Joi.object({ sample_id: IdValidate.get().required(), - number: this.condition.number.required(), parameters: this.condition.parameters.required(), treatment_template: IdValidate.get().required() }).validate(data); } else if (param === 'change') { return Joi.object({ - number: this.condition.number, parameters: this.condition.parameters }).validate(data); } diff --git a/src/test/db.json b/src/test/db.json index 619fb75..62d18ea 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -221,7 +221,7 @@ { "_id": {"$oid":"700000000000000000000001"}, "sample_id": {"$oid":"400000000000000000000001"}, - "number": "B1", + "number": "A1", "parameters": { "material": "copper", "weeks": 3 @@ -233,7 +233,7 @@ { "_id": {"$oid":"700000000000000000000002"}, "sample_id": {"$oid":"400000000000000000000002"}, - "number": "B1", + "number": "A3", "parameters": { "material": "copper", "weeks": 3 @@ -245,7 +245,7 @@ { "_id": {"$oid":"700000000000000000000003"}, "sample_id": {"$oid":"400000000000000000000004"}, - "number": "B1", + "number": "A4", "parameters": { "material": "copper", "weeks": 3 @@ -257,7 +257,7 @@ { "_id": {"$oid":"700000000000000000000004"}, "sample_id": {"$oid":"400000000000000000000001"}, - "number": "B3", + "number": "A6", "parameters": { "material": "hot air", "weeks": 5 From d2c7ec2368c11a47ff2e5b7913f59a514609768f Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 15 May 2020 14:55:01 +0200 Subject: [PATCH 36/83] material numbers defined as string, colors without numbers can be added --- api/schemas.yaml | 2 +- src/models/material.ts | 4 +- src/routes/condition.spec.ts | 2 +- src/routes/material.spec.ts | 115 ++++++++++++++++++++++---------- src/routes/validate/material.ts | 5 +- src/test/db.json | 31 +++++++-- 6 files changed, 112 insertions(+), 47 deletions(-) diff --git a/api/schemas.yaml b/api/schemas.yaml index 7141cbb..3be9b11 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -111,7 +111,7 @@ Material: - $ref: 'api.yaml#/components/schemas/Color' properties: number: - type: number + type: string example: 5514263423 Condition: diff --git a/src/models/material.ts b/src/models/material.ts index a5378e0..71d6b34 100644 --- a/src/models/material.ts +++ b/src/models/material.ts @@ -9,9 +9,9 @@ const MaterialSchema = new mongoose.Schema({ carbon_fiber: String, numbers: [{ color: String, - number: Number + number: String }], status: Number -}); +}, {minimize: false}); export default mongoose.model('material', MaterialSchema); \ No newline at end of file diff --git a/src/routes/condition.spec.ts b/src/routes/condition.spec.ts index 5881e4b..8b4a73c 100644 --- a/src/routes/condition.spec.ts +++ b/src/routes/condition.spec.ts @@ -333,7 +333,7 @@ describe('/condition', () => { }); }); - describe('POST /condition/new', () => { // TODO: sample number generation + describe('POST /condition/new', () => { it('returns the right condition', done => { TestHelper.request(server, done, { method: 'post', diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 0faf04e..746a620 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -1,8 +1,8 @@ import should from 'should/as-function'; +import _ from 'lodash'; import MaterialModel from '../models/material'; import TestHelper from "../test/helper"; -// TODO: numbers with color only (no number) // TODO: deal with numbers with leading zeros describe('/material', () => { @@ -21,7 +21,6 @@ describe('/material', () => { }).end((err, res) => { if (err) return done(err); const json = require('../test/db.json'); - console.log(res.body); should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === 10).length); should(res.body).matchEach(material => { should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); @@ -35,7 +34,7 @@ describe('/material', () => { should(material.numbers).matchEach(number => { should(number).have.only.keys('color', 'number'); should(number).have.property('color').be.type('string'); - should(number).have.property('number').be.type('number'); + should(number).have.property('number').be.type('string'); }); }); done(); @@ -63,7 +62,7 @@ describe('/material', () => { should(material.numbers).matchEach(number => { should(number).have.only.keys('color', 'number'); should(number).have.property('color').be.type('string'); - should(number).have.property('number').be.type('number'); + should(number).have.property('number').be.type('string'); }); }); done(); @@ -85,7 +84,7 @@ describe('/material', () => { url: '/material/100000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}, {color: 'natural', number: 5514263422}]} + res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]} }); }); it('returns the right material for an API key', done => { @@ -97,6 +96,15 @@ describe('/material', () => { res: {_id: '100000000000000000000003', name: 'PA GF 50 black (2706)', supplier: 'Akro-Plastic', group: 'PA66+PA6I/6T', mineral: 0, glass_fiber: 0, carbon_fiber: 0, numbers: []} }); }); + it('returns a material with a color without number', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000007', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '100000000000000000000007', name: 'Ultramid A4H', supplier: 'BASF', group: 'PA66', mineral: 0, glass_fiber: 0, carbon_fiber: 0, numbers: [{color: 'black', number: ''}]} + }); + }); it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'get', @@ -130,7 +138,7 @@ describe('/material', () => { auth: {basic: 'janedoe'}, httpStatus: 200, req: {}, - res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}, {color: 'natural', number: 5514263422}]} + res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]} }); }); it('keeps unchanged properties', done => { @@ -139,10 +147,10 @@ describe('/material', () => { url: '/material/100000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}, {color: 'natural', number: 5514263422}]} + req: {name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}, {color: 'natural', number: 5514263422}]}); + should(res.body).be.eql({_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]}); MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { if (err) return done(err); should(data).have.property('status', 10); @@ -159,7 +167,7 @@ describe('/material', () => { req: {name: 'Stanyl TW 200 F8'} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}, {color: 'natural', number: 5514263422}]}); + should(res.body).be.eql({_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]}); MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { if (err) return done(err); should(data).have.property('status', 10); @@ -173,20 +181,30 @@ describe('/material', () => { url: '/material/100000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}]} + req: {name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]} , }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}]}); + should(res.body).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]}); MaterialModel.findById('100000000000000000000001').lean().exec((err, data:any) => { if (err) return done(err); data._id = data._id.toString(); data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); - should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: '0', glass_fiber: '35', carbon_fiber: '0', numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}], status: 0, __v: 0}); + should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: '0', glass_fiber: '35', carbon_fiber: '0', numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: 0, __v: 0}); done(); }); }); }); + it('accepts a color without number', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000007', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {numbers: [{color: 'black', number: ''}, {color: 'natural', number: ''}]}, + res: {_id: '100000000000000000000007', name: 'Ultramid A4H', supplier: 'BASF', group: 'PA66', mineral: 0, glass_fiber: 0, carbon_fiber: 0, numbers: [{color: 'black', number: ''}, {color: 'natural', number: ''}]} + }); + }) it('rejects already existing material names', done => { TestHelper.request(server, done, { method: 'put', @@ -233,20 +251,10 @@ describe('/material', () => { url: '/material/100000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {numbers: [{colorxx: 'black', number: 55}]}, + req: {numbers: [{colorxx: 'black', number: '55'}]}, res: {status: 'Invalid body format', details: '"numbers[0].color" is required'} }); }); - it('rejects a wrong color number property', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/material/100000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {numbers: [{color: 'black', number: 'xxx'}]}, - res: {status: 'Invalid body format', details: '"numbers[0].number" must be a number'} - }); - }); it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'put', @@ -307,7 +315,7 @@ describe('/material', () => { if (err) return done(err); data._id = data._id.toString(); data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); - should(data).be.eql({_id: '100000000000000000000002', name: 'Ultramid T KR 4355 G7', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: 5514212901}, {color: 'signalviolet', number: 5514612901}], status: -1, __v: 0} + should(data).be.eql({_id: '100000000000000000000002', name: 'Ultramid T KR 4355 G7', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: -1, __v: 0} ); done(); }); @@ -370,7 +378,7 @@ describe('/material', () => { url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]} + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]} }).end((err, res) => { if (err) return done (err); should(res.body).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); @@ -384,7 +392,7 @@ describe('/material', () => { should(res.body.numbers).matchEach(number => { should(number).have.only.keys('color', 'number'); should(number).have.property('color', 'black'); - should(number).have.property('number', 5515798402); + should(number).have.property('number', '5515798402'); }); done(); }); @@ -415,13 +423,52 @@ describe('/material', () => { }); }); }); + it('accepts a color without number', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: ''}]} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('name', 'Crastin CE 2510'); + should(res.body).have.property('supplier', 'Du Pont'); + should(res.body).have.property('group', 'PBT'); + should(res.body).have.property('mineral', 0); + should(res.body).have.property('glass_fiber', 30); + should(res.body).have.property('carbon_fiber', 0); + should(res.body.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color', 'black'); + should(number).have.property('number', ''); + }); + MaterialModel.find({name: 'Crastin CE 2510'}).lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v'); + should(data[0]).have.property('_id'); + should(data[0]).have.property('name', 'Crastin CE 2510'); + should(data[0]).have.property('supplier', 'Du Pont'); + should(data[0]).have.property('group', 'PBT'); + should(data[0]).have.property('mineral', '0'); + should(data[0]).have.property('glass_fiber', '30'); + should(data[0]).have.property('carbon_fiber', '0'); + should(data[0]).have.property('status', 0); + should(_.omit(data[0].numbers[0], '_id')).be.eql({color: 'black', number: ''}); + done(); + }); + }); + }); it('rejects already existing material names', done => { TestHelper.request(server, done, { method: 'post', url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: 5514263423}]}, + req: {name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}]}, res: {status: 'Material name already taken'} }); }); @@ -431,7 +478,7 @@ describe('/material', () => { url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + req: {supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]}, res: {status: 'Invalid body format', details: '"name" is required'} }); }); @@ -441,7 +488,7 @@ describe('/material', () => { url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {name: 'Crastin CE 2510', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + req: {name: 'Crastin CE 2510', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]}, res: {status: 'Invalid body format', details: '"supplier" is required'} }); }); @@ -451,7 +498,7 @@ describe('/material', () => { url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {name: 'Crastin CE 2510', supplier: 'Du Pont', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]}, res: {status: 'Invalid body format', details: '"group" is required'} }); }); @@ -461,7 +508,7 @@ describe('/material', () => { url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]}, res: {status: 'Invalid body format', details: '"mineral" is required'} }); }); @@ -471,7 +518,7 @@ describe('/material', () => { url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]}, res: {status: 'Invalid body format', details: '"glass_fiber" is required'} }); }); @@ -481,7 +528,7 @@ describe('/material', () => { url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, numbers: [{color: 'black', number: 5515798402}]}, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, numbers: [{color: 'black', number: '5515798402'}]}, res: {status: 'Invalid body format', details: '"carbon_fiber" is required'} }); }); @@ -501,7 +548,7 @@ describe('/material', () => { url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{number: 5515798402}]}, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{number: '5515798402'}]}, res: {status: 'Invalid body format', details: '"numbers[0].color" is required'} }); }); diff --git a/src/routes/validate/material.ts b/src/routes/validate/material.ts index c8b6e91..b09c494 100644 --- a/src/routes/validate/material.ts +++ b/src/routes/validate/material.ts @@ -33,8 +33,9 @@ export default class MaterialValidate { // validate input for material color: joi.string() .max(128) .required(), - number: joi.number() - .min(0) + number: joi.string() + .max(128) + .allow('') .required() })) }; diff --git a/src/test/db.json b/src/test/db.json index 62d18ea..fcd1631 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -121,11 +121,11 @@ "numbers": [ { "color": "black", - "number": 5514263423 + "number": "5514263423" }, { "color": "natural", - "number": 5514263422 + "number": "5514263422" } ], "status": 10, @@ -142,11 +142,11 @@ "numbers": [ { "color": "black", - "number": 5514212901 + "number": "5514212901" }, { "color": "signalviolet", - "number": 5514612901 + "number": "5514612901" } ], "status": 10, @@ -176,7 +176,7 @@ "numbers": [ { "color": "black", - "number": 5513933405 + "number": "5513933405" } ], "status": 10, @@ -193,7 +193,7 @@ "numbers": [ { "color": "black", - "number": 5514262406 + "number": "5514262406" } ], "status": 10, @@ -210,11 +210,28 @@ "numbers": [ { "color": "natural", - "number": 10000000 + "number": "10000000" } ], "status": -1, "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000007"}, + "name": "Ultramid A4H", + "supplier": "BASF", + "group": "PA66", + "mineral": 0, + "glass_fiber": 0, + "carbon_fiber": 0, + "numbers": [ + { + "color": "black", + "number": "" + } + ], + "status": 10, + "__v": 0 } ], "conditions": [ From 4100fb428e6e05c3548230951b32e53f7e2d5fea Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 15 May 2020 14:56:42 +0200 Subject: [PATCH 37/83] material numbers defined as string, colors without numbers and numbers with leading zeros can be added --- src/routes/material.spec.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 746a620..8b212aa 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -3,7 +3,6 @@ import _ from 'lodash'; import MaterialModel from '../models/material'; import TestHelper from "../test/helper"; -// TODO: deal with numbers with leading zeros describe('/material', () => { let server; @@ -378,7 +377,7 @@ describe('/material', () => { url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]} + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '05515798402'}]} }).end((err, res) => { if (err) return done (err); should(res.body).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); @@ -392,7 +391,7 @@ describe('/material', () => { should(res.body.numbers).matchEach(number => { should(number).have.only.keys('color', 'number'); should(number).have.property('color', 'black'); - should(number).have.property('number', '5515798402'); + should(number).have.property('number', '05515798402'); }); done(); }); From ec03e0699c0ae62baf8b22579cb53f206d254be8 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 15 May 2020 15:14:18 +0200 Subject: [PATCH 38/83] number prefixes are now not allowed to contain numbers --- .idea/libraries/dist.xml | 13 ++++++++++++ src/routes/template.spec.ts | 7 +++---- src/routes/validate/template.ts | 35 +++++++++++++++++---------------- 3 files changed, 34 insertions(+), 21 deletions(-) create mode 100644 .idea/libraries/dist.xml diff --git a/.idea/libraries/dist.xml b/.idea/libraries/dist.xml new file mode 100644 index 0000000..3d92275 --- /dev/null +++ b/.idea/libraries/dist.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index 68b99e9..c2e6fcd 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -4,7 +4,6 @@ import TemplateTreatmentModel from '../models/treatment_template'; import TemplateMeasurementModel from '../models/measurement_template'; import TestHelper from "../test/helper"; -// TODO: check number prefix to have no numbers describe('/template', () => { let server; @@ -371,14 +370,14 @@ describe('/template', () => { res: {status: 'Invalid body format', details: '"parameters[0].name" is required'} }); }); - it('rejects a missing number prefix', done => { + it('rejects a number prefix containing numbers', done => { TestHelper.request(server, done, { method: 'post', url: '/template/treatment/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}, - res: {status: 'Invalid body format', details: '"number_prefix" is required'} + req: {name: 'heat aging', number_prefix: 'AB5', parameters: [{name: 'time', range: {min: 1}}]}, + res: {status: 'Invalid body format', details: '"number_prefix" with value "AB5" fails to match the required pattern: /^[a-zA-Z]+$/'} }); }); it('rejects a missing parameter range', done => { diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts index 7cb461d..269e1a2 100644 --- a/src/routes/validate/template.ts +++ b/src/routes/validate/template.ts @@ -1,35 +1,36 @@ -import joi from '@hapi/joi'; +import Joi from '@hapi/joi'; import IdValidate from './id'; export default class TemplateValidate { private static template = { - name: joi.string() + name: Joi.string() .max(128), - version: joi.number() + version: Joi.number() .min(1), - number_prefix: joi.string() + number_prefix: Joi.string() + .pattern(/^[a-zA-Z]+$/) .min(1) .max(16), - parameters: joi.array() + parameters: Joi.array() .min(1) .items( - joi.object({ - name: joi.string() + Joi.object({ + name: Joi.string() .max(128) .required(), - range: joi.object({ - values: joi.array() + range: Joi.object({ + values: Joi.array() .min(1), - min: joi.number(), + min: Joi.number(), - max: joi.number(), + max: Joi.number(), - type: joi.string() + type: Joi.string() .valid('array') }) .oxor('values', 'min') @@ -45,14 +46,14 @@ export default class TemplateValidate { static input (data, param, template) { // validate data, param: new(everything required)/change(available attributes are validated) if (param === 'new') { if (template === 'treatment') { - return joi.object({ + return Joi.object({ name: this.template.name.required(), number_prefix: this.template.number_prefix.required(), parameters: this.template.parameters.required() }).validate(data); } else { - return joi.object({ + return Joi.object({ name: this.template.name.required(), parameters: this.template.parameters.required() }).validate(data); @@ -60,14 +61,14 @@ export default class TemplateValidate { } else if (param === 'change') { if (template === 'treatment') { - return joi.object({ + return Joi.object({ name: this.template.name, number_prefix: this.template.number_prefix, parameters: this.template.parameters }).validate(data); } else { - return joi.object({ + return Joi.object({ name: this.template.name, parameters: this.template.parameters }).validate(data); @@ -98,7 +99,7 @@ export default class TemplateValidate { parameters: this.template.parameters }; } - const {value, error} = joi.object(joiObject).validate(data, {stripUnknown: true}); + const {value, error} = Joi.object(joiObject).validate(data, {stripUnknown: true}); return error !== undefined? null : value; } } \ No newline at end of file From fe6e82f00bbae5eefd07d614f35dac8e1db8b6a8 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 18 May 2020 09:58:15 +0200 Subject: [PATCH 39/83] sample number generation --- api/schemas.yaml | 1 + src/helpers/authorize.ts | 9 +-- src/routes/condition.spec.ts | 31 ++++++++-- src/routes/condition.ts | 5 +- src/routes/sample.spec.ts | 105 +++++++++++++++++++--------------- src/routes/sample.ts | 23 ++++---- src/routes/validate/sample.ts | 2 - src/test/db.json | 19 ++++-- src/test/helper.ts | 3 +- 9 files changed, 121 insertions(+), 77 deletions(-) diff --git a/api/schemas.yaml b/api/schemas.yaml index 3be9b11..c872443 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -16,6 +16,7 @@ SampleProperties: properties: number: type: string + readOnly: true example: Rng172 type: type: string diff --git a/src/helpers/authorize.ts b/src/helpers/authorize.ts index e2f626a..c2404a4 100644 --- a/src/helpers/authorize.ts +++ b/src/helpers/authorize.ts @@ -9,7 +9,7 @@ import UserModel from '../models/user'; module.exports = async (req, res, next) => { let givenMethod = ''; // authorization method given by client, basic taken preferred - let user = {name: '', level: '', id: ''}; // user object + let user = {name: '', level: '', id: '', location: ''}; // user object // test authentications const userBasic = await basic(req, next); @@ -46,7 +46,8 @@ module.exports = async (req, res, next) => { method: givenMethod, username: user.name, level: user.level, - id: user.id + id: user.id, + location: user.location }; next(); @@ -63,7 +64,7 @@ function basic (req, next): any { // checks basic auth and returns changed user bcrypt.compare(auth.pass, data[0].pass, (err, res) => { // check password if (err) return next(err); if (res === true) { - resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString()}); + resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString(), location: data[0].location}); } else { resolve(null); @@ -87,7 +88,7 @@ function key (req, next): any { // checks API key and returns changed user obje UserModel.find({key: req.query.key}).lean().exec( (err, data: any) => { // find user if (err) return next(err); if (data.length === 1) { // one user found - resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString()}); + resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString(), location: data[0].location}); } else { resolve(null); diff --git a/src/routes/condition.spec.ts b/src/routes/condition.spec.ts index 8b4a73c..ef01c89 100644 --- a/src/routes/condition.spec.ts +++ b/src/routes/condition.spec.ts @@ -253,7 +253,7 @@ describe('/condition', () => { if (err) return done(err); should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', 'status', '__v'); should(data.sample_id.toString()).be.eql('400000000000000000000001'); - should(data).have.property('number', 'A6'); + should(data).have.property('number', 'A2'); should(data.treatment_template.toString()).be.eql('200000000000000000000001'); should(data).have.property('status', -1); should(data).have.property('parameters'); @@ -346,7 +346,7 @@ describe('/condition', () => { should(res.body).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template'); should(res.body).have.property('_id').be.type('string'); should(res.body).have.property('sample_id', '400000000000000000000002'); - should(res.body).have.property('number', 'A7'); + should(res.body).have.property('number', 'A2'); should(res.body).have.property('treatment_template', '200000000000000000000001'); should(res.body).have.property('parameters'); should(res.body.parameters).have.property('material', 'hot air'); @@ -367,7 +367,30 @@ describe('/condition', () => { if (err) return done(err); should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', 'status', '__v'); should(data.sample_id.toString()).be.eql('400000000000000000000002'); - should(data).have.property('number', 'A7'); + should(data).have.property('number', 'A2'); + should(data.treatment_template.toString()).be.eql('200000000000000000000001'); + should(data).have.property('status', 0); + should(data).have.property('parameters'); + should(data.parameters).have.property('material', 'hot air'); + should(data.parameters).have.property('weeks', 10); + done(); + }); + }); + }); + it('stores the first condition as 1', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000003', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }).end((err, res) => { + if (err) return done(err); + ConditionModel.findById(res.body._id).lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', 'status', '__v'); + should(data.sample_id.toString()).be.eql('400000000000000000000003'); + should(data).have.property('number', 'A1'); should(data.treatment_template.toString()).be.eql('200000000000000000000001'); should(data).have.property('status', 0); should(data).have.property('parameters'); @@ -518,7 +541,7 @@ describe('/condition', () => { should(res.body).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template'); should(res.body).have.property('_id').be.type('string'); should(res.body).have.property('sample_id', '400000000000000000000002'); - should(res.body).have.property('number', 'A7'); + should(res.body).have.property('number', 'A2'); should(res.body).have.property('treatment_template', '200000000000000000000001'); should(res.body).have.property('parameters'); should(res.body.parameters).have.property('material', 'hot air'); diff --git a/src/routes/condition.ts b/src/routes/condition.ts index 1e54a00..71dc27b 100644 --- a/src/routes/condition.ts +++ b/src/routes/condition.ts @@ -107,15 +107,14 @@ async function sampleIdCheck (condition, req, res, next) { // validate sample_i async function numberGenerate (condition, treatmentData, next) { // validate number, returns false if invalid const conditionData = await ConditionModel - .find({number: new RegExp('^' + treatmentData.number_prefix + '[0-9]+$', 'm')}) + .find({sample_id: condition.sample_id, number: new RegExp('^' + treatmentData.number_prefix + '[0-9]+$', 'm')}) .sort({number: -1}) .limit(1) .lean() .exec() .catch(err => next(err)) as any; if (conditionData instanceof Error) return false; - console.log(conditionData); - return treatmentData.number_prefix + (Number(conditionData[0].number.replace(/[^0-9]+/g, '')) + 1); + return treatmentData.number_prefix + (conditionData.length > 0 ? Number(conditionData[0].number.replace(/[^0-9]+/g, '')) + 1 : 1); } async function treatmentCheck (condition, param, res, next) { // validate treatment template, returns false if invalid, otherwise template data diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 8efce68..581ee0c 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -3,7 +3,7 @@ import SampleModel from '../models/sample'; import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; import TestHelper from "../test/helper"; -// TODO: generate sample number + // TODO: think again which parameters are required at POST describe('/sample', () => { @@ -87,7 +87,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', notes: {}} + req: {type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', notes: {}} }).end((err, res) => { if (err) return done(err); should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}); @@ -156,14 +156,14 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} }).end(err => { if (err) return done (err); SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { if (err) return done (err); should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', 'status', '__v'); should(data).have.property('_id'); - should(data).have.property('number', '10'); + should(data).have.property('number', '1'); should(data).have.property('color', 'signalviolet'); should(data).have.property('type', 'part'); should(data).have.property('batch', '114531'); @@ -228,7 +228,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000002', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {number: '111'} + req: {type: 'part'} }).end((err, res) => { if (err) return done (err); NoteModel.findById(res.body.note_id).lean().exec((err, data) => { @@ -263,7 +263,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Color not available for material'} }); }); @@ -273,18 +273,18 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '000000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '000000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Material not available'} }); }); - it('rejects a sample number in use', done => { + it('rejects a sample number', done => { TestHelper.request(server, done, { method: 'put', url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: '21', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Sample number already taken'} + req: {number: 25, type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format', details: '"number" is not allowed'} }); }); it('rejects an invalid sample reference', done => { @@ -293,7 +293,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '000000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '000000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Sample reference not available'} }); }); @@ -303,7 +303,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'} }); }); @@ -313,7 +313,7 @@ describe('/sample', () => { url: '/sample/10000000000h000000000001', auth: {basic: 'janedoe'}, httpStatus: 404, - req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, }); }); it('rejects an API key', done => { @@ -322,7 +322,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {key: 'janedoe'}, httpStatus: 401, - req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, }); }); it('rejects changes for samples from another user for a write user', done => { @@ -350,7 +350,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'user'}, httpStatus: 403, - req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, }); }); it('returns 404 for an unknown sample', done => { @@ -359,7 +359,7 @@ describe('/sample', () => { url: '/sample/000000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 404, - req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} }); }) it('rejects unauthorized requests', done => { @@ -367,7 +367,7 @@ describe('/sample', () => { method: 'put', url: '/sample/400000000000000000000001', httpStatus: 401, - req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, }); }); }); @@ -531,12 +531,12 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} }).end((err, res) => { if (err) return done (err); should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id'); should(res.body).have.property('_id').be.type('string'); - should(res.body).have.property('number', 'Rng172'); + should(res.body).have.property('number', 'Rng34'); should(res.body).have.property('color', 'black'); should(res.body).have.property('type', 'granulate'); should(res.body).have.property('batch', '1560237365'); @@ -552,15 +552,15 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} }).end(err => { if (err) return done (err); - SampleModel.find({number: 'Rng172'}).lean().exec((err, data: any) => { + SampleModel.find({number: 'Rng34'}).lean().exec((err, data: any) => { if (err) return done (err); should(data).have.lengthOf(1); should(data[0]).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', 'status', '__v'); should(data[0]).have.property('_id'); - should(data[0]).have.property('number', 'Rng172'); + should(data[0]).have.property('number', 'Rng34'); should(data[0]).have.property('color', 'black'); should(data[0]).have.property('type', 'granulate'); should(data[0]).have.property('batch', '1560237365'); @@ -587,7 +587,7 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [], custom_fields: {field1: 'a', field2: 'b', 'not allowed for new applications': true}}} + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [], custom_fields: {field1: 'a', field2: 'b', 'not allowed for new applications': true}}} }).end((err, res) => { if (err) return done (err); NoteModel.findById(res.body.note_id).lean().exec((err, data: any) => { @@ -618,13 +618,34 @@ describe('/sample', () => { }); }); }); + it('stores a new sample location as 1', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'johnnydoe'}, + httpStatus: 200, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('number', 'Fe1'); + should(res.body).have.property('color', 'black'); + should(res.body).have.property('type', 'granulate'); + should(res.body).have.property('batch', '1560237365'); + should(res.body).have.property('material_id', '100000000000000000000001'); + should(res.body).have.property('note_id').be.type('string'); + should(res.body).have.property('user_id', '000000000000000000000004'); + done(); + }); + }); it('rejects a color not defined for the material', done => { TestHelper.request(server, done, { method: 'post', url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: 'Rng172', color: 'green', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'green', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Color not available for material'} }); }); @@ -634,18 +655,18 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '000000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '000000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Material not available'} }); }); - it('rejects a sample number in use', done => { + it('rejects a sample number', done => { TestHelper.request(server, done, { method: 'post', url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: '1', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Sample number already taken'} + req: {number: 'Rng34', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format', details: '"number" is not allowed'} }); }); it('rejects an invalid sample reference', done => { @@ -654,7 +675,7 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '000000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '000000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Sample reference not available'} }); }); @@ -664,27 +685,17 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: 'Rng172', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"color" is required'} }); }); - it('rejects a missing sample number', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/sample/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Invalid body format', details: '"number" is required'} - }); - }); it('rejects a missing type', done => { TestHelper.request(server, done, { method: 'post', url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: 'Rng172', color: 'black', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'black', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"type" is required'} }); }); @@ -694,7 +705,7 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: 'Rng172', color: 'black', type: 'granulate', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'black', type: 'granulate', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"batch" is required'} }); }); @@ -704,7 +715,7 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'black', type: 'granulate', batch: '1560237365', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"material_id" is required'} }); }); @@ -714,7 +725,7 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'} }); }); @@ -724,7 +735,7 @@ describe('/sample', () => { url: '/sample/new', auth: {key: 'janedoe'}, httpStatus: 401, - req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} }); }); it('rejects requests from a read user', done => { @@ -733,7 +744,7 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'user'}, httpStatus: 403, - req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} }); }); it('rejects unauthorized requests', done => { @@ -741,7 +752,7 @@ describe('/sample', () => { method: 'post', url: '/sample/new', httpStatus: 401, - req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} }); }); }); diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 6acb7d2..38e2282 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -36,9 +36,6 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { // only maintain and admin are allowed to edit other user's data if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; - if (sample.hasOwnProperty('number') && sample.number !== sampleData.number) { - if (!await numberCheck(sample, res, next)) return; - } if (sample.hasOwnProperty('material_id')) { if (!await materialCheck(sample, res, next)) return; } @@ -120,7 +117,6 @@ router.post('/sample/new', async (req, res, next) => { const {error, value: sample} = SampleValidate.input(req.body, 'new'); if (error) return res400(error, res); - if (!await numberCheck(sample, res, next)) return; if (!await materialCheck(sample, res, next)) return; if (!await sampleRefCheck(sample, res, next)) return; @@ -129,6 +125,8 @@ router.post('/sample/new', async (req, res, next) => { } sample.status = 0; + sample.number = await numberGenerate(sample, req, res, next); + if (!sample.number) return; new NoteModel(sample.notes).save((err, data) => { if (err) return next(err); delete sample.notes; @@ -155,17 +153,18 @@ router.get('/sample/notes/fields', (req, res, next) => { module.exports = router; -async function numberCheck (sample, res, next) { // validate number, returns false if invalid - const sampleData = await SampleModel.findOne({number: sample.number}).lean().exec().catch(err => {next(err); return false;}); - if (sampleData) { // found entry with sample number - res.status(400).json({status: 'Sample number already taken'}); - return false - } - return true; +async function numberGenerate (sample, req, res, next) { // validate number, returns false if invalid + const sampleData = await SampleModel + .find({number: new RegExp('^' + req.authDetails.location + '[0-9]+$', 'm')}) + .lean() + .exec() + .catch(err => next(err)); + if (sampleData instanceof Error) return false; + return req.authDetails.location + (sampleData.length > 0 ? Number(sampleData[0].number.replace(/[^0-9]+/g, '')) + 1 : 1); } async function materialCheck (sample, res, next, id = sample.material_id) { // validate material_id and color, returns false if invalid - const materialData = await MaterialModel.findById(id).lean().exec().catch(err => {next(err); return false;}) as any; + const materialData = await MaterialModel.findById(id).lean().exec().catch(err => next(err)) as any; if (materialData instanceof Error) return false; if (!materialData) { // could not find material_id res.status(400).json({status: 'Material not available'}); diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index aa28304..9373152 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -44,7 +44,6 @@ export default class SampleValidate { static input (data, param) { // validate data, param: new(everything required)/change(available attributes are validated) if (param === 'new') { return Joi.object({ - number: this.sample.number.required(), color: this.sample.color.required(), type: this.sample.type.required(), batch: this.sample.batch.required(), @@ -54,7 +53,6 @@ export default class SampleValidate { } else if (param === 'change') { return Joi.object({ - number: this.sample.number, color: this.sample.color, type: this.sample.type, batch: this.sample.batch, diff --git a/src/test/db.json b/src/test/db.json index fcd1631..7154863 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -51,7 +51,7 @@ }, { "_id": {"$oid":"400000000000000000000005"}, - "number": "33", + "number": "Rng33", "type": "granulate", "color": "black", "batch": "1653000308", @@ -250,7 +250,7 @@ { "_id": {"$oid":"700000000000000000000002"}, "sample_id": {"$oid":"400000000000000000000002"}, - "number": "A3", + "number": "A1", "parameters": { "material": "copper", "weeks": 3 @@ -262,7 +262,7 @@ { "_id": {"$oid":"700000000000000000000003"}, "sample_id": {"$oid":"400000000000000000000004"}, - "number": "A4", + "number": "A1", "parameters": { "material": "copper", "weeks": 3 @@ -274,7 +274,7 @@ { "_id": {"$oid":"700000000000000000000004"}, "sample_id": {"$oid":"400000000000000000000001"}, - "number": "A6", + "number": "A2", "parameters": { "material": "hot air", "weeks": 5 @@ -446,6 +446,17 @@ "device_name": "", "key": "000000000000000000001003", "__v": "0" + }, + { + "_id": {"$oid":"000000000000000000000004"}, + "email": "johnny.doe@bosch.com", + "name": "johnnydoe", + "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi", + "level": "write", + "location": "Fe", + "device_name": "Alpha I", + "key": "000000000000000000001004", + "__v": 0 } ] } diff --git a/src/test/helper.ts b/src/test/helper.ts index 26cb5a5..b7ff49f 100644 --- a/src/test/helper.ts +++ b/src/test/helper.ts @@ -7,7 +7,8 @@ export default class TestHelper { public static auth = { admin: {pass: 'Abc123!#', key: '000000000000000000001003'}, janedoe: {pass: 'Xyz890*)', key: '000000000000000000001002'}, - user: {pass: 'Xyz890*)', key: '000000000000000000001001'} + user: {pass: 'Xyz890*)', key: '000000000000000000001001'}, + johnnydoe: {pass: 'Xyz890*)', key: '000000000000000000001004'} } public static res = { 400: {status: 'Bad request'}, From 70aca017f831139add0ead3dc73afb9d6f10455b Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 18 May 2020 10:43:26 +0200 Subject: [PATCH 40/83] /materials/new|deleted --- api/material.yaml | 25 +++++++++- api/parameters.yaml | 12 ++++- src/index.ts | 1 + src/routes/material.spec.ts | 95 +++++++++++++++++++++++++++++++++++++ src/routes/material.ts | 17 +++++++ src/test/db.json | 2 +- 6 files changed, 149 insertions(+), 3 deletions(-) diff --git a/api/material.yaml b/api/material.yaml index 9775b29..d184a3f 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -2,7 +2,30 @@ get: summary: lists all materials description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: returns only materials with status 10 # TODO: methods /materials/new|deleted + x-doc: returns only materials with status 10 + tags: + - /material + responses: + 200: + description: all material details + content: + application/json: + schema: + type: array + items: + $ref: 'api.yaml#/components/schemas/Material' + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' + +/materials/{group}: + parameters: + - $ref: 'api.yaml#/components/parameters/Group' + get: + summary: lists all new/deleted materials + description: 'Auth: basic, levels: maintain, admin' + x-doc: returns materials with status 0/-1 tags: - /material responses: diff --git a/api/parameters.yaml b/api/parameters.yaml index ba8d046..b4586f7 100644 --- a/api/parameters.yaml +++ b/api/parameters.yaml @@ -5,10 +5,20 @@ Id: schema: type: string example: 5ea0450ed851c30a90e70894 + Name: name: name description: has to be URL encoded in: path required: true schema: - type: string \ No newline at end of file + type: string + +Group: + name: group + description: 'possible values: new, deleted' + in: path + required: true + schema: + type: string + example: deleted \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index fc1b149..c40ed24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import mongoSanitize from 'mongo-sanitize'; import api from './api'; import db from './db'; +// TODO: overall commenting/documentation review // tell if server is running in debug or production environment console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 8b212aa..df58310 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -76,6 +76,101 @@ describe('/material', () => { }); }); + describe('GET /materials/{group}', () => { + it('returns all new materials', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials/new', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + let asyncCounter = res.body.length; + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === 0).length); + should(res.body).matchEach(material => { + should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(material).have.property('_id').be.type('string'); + should(material).have.property('name').be.type('string'); + should(material).have.property('supplier').be.type('string'); + should(material).have.property('group').be.type('string'); + should(material).have.property('mineral').be.type('number'); + should(material).have.property('glass_fiber').be.type('number'); + should(material).have.property('carbon_fiber').be.type('number'); + should(material.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color').be.type('string'); + should(number).have.property('number').be.type('string'); + }); + MaterialModel.findById(material._id).lean().exec((err, data) => { + should(data).have.property('status', 0); + if (--asyncCounter === 0) { + done(); + } + }); + }); + }); + }); + it('returns all deleted materials', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials/deleted', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + let asyncCounter = res.body.length; + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === -1).length); + should(res.body).matchEach(material => { + should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(material).have.property('_id').be.type('string'); + should(material).have.property('name').be.type('string'); + should(material).have.property('supplier').be.type('string'); + should(material).have.property('group').be.type('string'); + should(material).have.property('mineral').be.type('number'); + should(material).have.property('glass_fiber').be.type('number'); + should(material).have.property('carbon_fiber').be.type('number'); + should(material.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color').be.type('string'); + should(number).have.property('number').be.type('string'); + }); + MaterialModel.findById(material._id).lean().exec((err, data) => { + should(data).have.property('status', -1); + if (--asyncCounter === 0) { + done(); + } + }); + }); + done(); + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials/new', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials/deleted', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials/new', + httpStatus: 401 + }); + }); + }); + describe('GET /material/{id}', () => { it('returns the right material', done => { TestHelper.request(server, done, { diff --git a/src/routes/material.ts b/src/routes/material.ts index 1c33591..2de50a8 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -21,6 +21,23 @@ router.get('/materials', (req, res, next) => { }); }); +router.get('/materials/:group(new|deleted)', (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + let status; + switch (req.params.group) { + case 'new': status = 0; + break; + case 'deleted': status = -1; + break; + } + MaterialModel.find({status: status}).lean().exec((err, data) => { + if (err) return next(err); + console.log(data); + res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors + }); +}); + router.get('/material/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; diff --git a/src/test/db.json b/src/test/db.json index 7154863..b78f8e7 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -230,7 +230,7 @@ "number": "" } ], - "status": 10, + "status": 0, "__v": 0 } ], From 5209410009db1abd0bfd3790f17cac324fabaf10 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 18 May 2020 14:47:22 +0200 Subject: [PATCH 41/83] refactored user.ts --- api/sample.yaml | 28 ++++++- src/api.ts | 10 +-- src/db.ts | 10 +-- src/helpers/authorize.ts | 4 +- src/helpers/mail.ts | 2 +- src/index.ts | 2 +- src/routes/condition.ts | 11 +-- src/routes/material.ts | 8 +- src/routes/measurement.ts | 9 ++- src/routes/sample.spec.ts | 113 +++++++++++++++++++++++---- src/routes/sample.ts | 44 ++++++++--- src/routes/template.spec.ts | 1 - src/routes/template.ts | 12 +-- src/routes/user.ts | 121 ++++++++++++++--------------- src/routes/validate/condition.ts | 4 +- src/routes/validate/id.ts | 11 ++- src/routes/validate/material.ts | 4 +- src/routes/validate/measurement.ts | 4 +- src/routes/validate/note_field.ts | 2 +- src/routes/validate/parameters.ts | 2 +- src/routes/validate/res400.ts | 2 + src/routes/validate/sample.ts | 4 +- src/routes/validate/template.ts | 6 +- src/routes/validate/user.ts | 4 +- src/test/helper.ts | 18 ++--- src/test/loadDev.ts | 2 + 26 files changed, 282 insertions(+), 156 deletions(-) diff --git a/api/sample.yaml b/api/sample.yaml index 30500b4..c699809 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -2,7 +2,7 @@ get: summary: all samples in overview description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: returns only samples with status 10 # TODO: methods /samples/new|deleted + x-doc: returns only samples with status 10 tags: - /sample responses: @@ -18,6 +18,30 @@ $ref: 'api.yaml#/components/responses/401' 500: $ref: 'api.yaml#/components/responses/500' + +/samples{group}: + parameters: + - $ref: 'api.yaml#/components/parameters/Group' + get: + summary: all new/deleted samples in overview + description: 'Auth: basic, levels: maintain, admin' + x-doc: returns only samples with status 0/-1 + tags: + - /sample + responses: + 200: + description: samples overview + content: + application/json: + schema: + type: array + items: + $ref: 'api.yaml#/components/schemas/SampleRefs' + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' + /sample/{id}: parameters: - $ref: 'api.yaml#/components/parameters/Id' @@ -130,7 +154,7 @@ get: summary: list all existing field names for custom notes fields description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: integrity has to be ensured # TODO: implement mechanism to regularly check note_fields + x-doc: integrity has to be ensured tags: - /sample responses: diff --git a/src/api.ts b/src/api.ts index 228f166..625e738 100644 --- a/src/api.ts +++ b/src/api.ts @@ -20,7 +20,7 @@ export default class api { apiDoc = doc; apiDoc.paths = apiDoc.paths.allOf.reduce((s, e) => Object.assign(s, e)); // bundle routes apiDoc = this.resolveXDoc(apiDoc); - oasParser.validate(apiDoc, (err, api) => { + oasParser.validate(apiDoc, (err, api) => { // validate oas schema if (err) { console.error(err); } @@ -35,8 +35,8 @@ export default class api { private static resolveXDoc (doc) { // resolve x-doc properties recursively Object.keys(doc).forEach(key => { - if (doc[key] !== null && doc[key].hasOwnProperty('x-doc')) { - doc[key].description += this.addHtml(doc[key]['x-doc']); + if (doc[key] !== null && doc[key].hasOwnProperty('x-doc')) { // add x-doc to description, is styled via css + doc[key].description += '
    docs' + doc[key]['x-doc'] + '
    '; } else if (typeof doc[key] === 'object' && doc[key] !== null) { // go deeper into recursion doc[key] = this.resolveXDoc(doc[key]); @@ -44,8 +44,4 @@ export default class api { }); return doc; } - - private static addHtml (text) { // add docs HTML - return '
    docs' + text + '
    '; - } } \ No newline at end of file diff --git a/src/db.ts b/src/db.ts index 89c3183..c1d1fbb 100644 --- a/src/db.ts +++ b/src/db.ts @@ -13,7 +13,7 @@ export default class db { 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 + static connect (mode = '', done: Function = () => {}) { // set mode to test for unit/integration tests, otherwise skip parameters. done is also only needed for testing if (this.state.db) return done(); // db is already connected // find right connection url @@ -84,9 +84,9 @@ export default class db { } 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) { + if (!this.state.db || !json.hasOwnProperty('collections') || json.collections.length === 0) { // no db connection or nothing to load 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 @@ -103,10 +103,10 @@ export default class db { private static oidResolve (object: any) { // resolve $oid fields to actual ObjectIds recursively Object.keys(object).forEach(key => { - if (object[key] !== null && object[key].hasOwnProperty('$oid')) { + if (object[key] !== null && object[key].hasOwnProperty('$oid')) { // found oid, replace object[key] = mongoose.Types.ObjectId(object[key].$oid); } - else if (typeof object[key] === 'object' && object[key] !== null) { + else if (typeof object[key] === 'object' && object[key] !== null) { // deeper into recursion object[key] = this.oidResolve(object[key]); } }); diff --git a/src/helpers/authorize.ts b/src/helpers/authorize.ts index c2404a4..21d43d5 100644 --- a/src/helpers/authorize.ts +++ b/src/helpers/authorize.ts @@ -63,7 +63,7 @@ function basic (req, next): any { // checks basic auth and returns changed user if (data.length === 1) { // one user found bcrypt.compare(auth.pass, data[0].pass, (err, res) => { // check password if (err) return next(err); - if (res === true) { + if (res === true) { // password correct resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString(), location: data[0].location}); } else { @@ -84,7 +84,7 @@ function basic (req, next): any { // checks basic auth and returns changed user function key (req, next): any { // checks API key and returns changed user object return new Promise(resolve => { - if (req.query.key !== undefined) { + if (req.query.key !== undefined) { // key available UserModel.find({key: req.query.key}).lean().exec( (err, data: any) => { // find user if (err) return next(err); if (data.length === 1) { // one user found diff --git a/src/helpers/mail.ts b/src/helpers/mail.ts index 792f35f..a3d79c1 100644 --- a/src/helpers/mail.ts +++ b/src/helpers/mail.ts @@ -1,6 +1,6 @@ import axios from 'axios'; -// sends an email +// sends an email using the BIC service export default (mailAddress, subject, content, f) => { // callback, executed empty or with error if (process.env.NODE_ENV === 'production') { diff --git a/src/index.ts b/src/index.ts index c40ed24..362f5cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import mongoSanitize from 'mongo-sanitize'; import api from './api'; import db from './db'; -// TODO: overall commenting/documentation review + // tell if server is running in debug or production environment console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); diff --git a/src/routes/condition.ts b/src/routes/condition.ts index 71dc27b..f66d10a 100644 --- a/src/routes/condition.ts +++ b/src/routes/condition.ts @@ -37,13 +37,14 @@ router.put('/condition/' + IdValidate.parameter(), async (req, res, next) => { if (!data) { res.status(404).json({status: 'Not found'}); } + // add properties needed for sampleIdCheck condition.treatment_template = data.treatment_template; condition.sample_id = data.sample_id; if (!await sampleIdCheck(condition, req, res, next)) return; if (condition.parameters) { condition.parameters = _.assign({}, data.parameters, condition.parameters); - if (!_.isEqual(condition.parameters, data.parameters)) { + if (!_.isEqual(condition.parameters, data.parameters)) { // parameters did not change condition.status = 0; } } @@ -83,7 +84,7 @@ router.post('/condition/new', async (req, res, next) => { condition.number = await numberGenerate(condition, treatmentData, next); if (!condition.number) return; - condition.status = 0; + condition.status = 0; // set status to new await new ConditionModel(condition).save((err, data) => { if (err) return next(err); res.json(ConditionValidate.output(data.toObject())); @@ -105,8 +106,8 @@ async function sampleIdCheck (condition, req, res, next) { // validate sample_i return true; } -async function numberGenerate (condition, treatmentData, next) { // validate number, returns false if invalid - const conditionData = await ConditionModel +async function numberGenerate (condition, treatmentData, next) { // generate number, returns false on error + const conditionData = await ConditionModel // find condition with highest number belonging to the same sample .find({sample_id: condition.sample_id, number: new RegExp('^' + treatmentData.number_prefix + '[0-9]+$', 'm')}) .sort({number: -1}) .limit(1) @@ -114,7 +115,7 @@ async function numberGenerate (condition, treatmentData, next) { // validate nu .exec() .catch(err => next(err)) as any; if (conditionData instanceof Error) return false; - return treatmentData.number_prefix + (conditionData.length > 0 ? Number(conditionData[0].number.replace(/[^0-9]+/g, '')) + 1 : 1); + return treatmentData.number_prefix + (conditionData.length > 0 ? Number(conditionData[0].number.replace(/[^0-9]+/g, '')) + 1 : 1); // return new number } async function treatmentCheck (condition, param, res, next) { // validate treatment template, returns false if invalid, otherwise template data diff --git a/src/routes/material.ts b/src/routes/material.ts index 2de50a8..dd89985 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -33,7 +33,6 @@ router.get('/materials/:group(new|deleted)', (req, res, next) => { } MaterialModel.find({status: status}).lean().exec((err, data) => { if (err) return next(err); - console.log(data); res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors }); }); @@ -68,7 +67,7 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { // check for changes if (!_.isEqual(_.pick(IdValidate.stringify(materialData), _.keys(material)), material)) { - material.status = 0; + material.status = 0; // set status to new } await MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).lean().exec((err, data) => { @@ -102,13 +101,12 @@ router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { router.post('/material/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; - // validate input const {error, value: material} = MaterialValidate.input(req.body, 'new'); if (error) return res400(error, res); if (!await nameCheck(material, res, next)) return; - material.status = 0; + material.status = 0; // set status to new await new MaterialModel(material).save((err, data) => { if (err) return next(err); res.json(MaterialValidate.output(data.toObject())); @@ -120,7 +118,7 @@ module.exports = router; async function nameCheck (material, res, next) { // check if name was already taken - const materialData = await MaterialModel.findOne({name: material.name}).lean().exec().catch(err => {next(err); return false;}) as any; + const materialData = await MaterialModel.findOne({name: material.name}).lean().exec().catch(err => next(err)) as any; if (materialData instanceof Error) return false; if (materialData) { // could not find material_id res.status(400).json({status: 'Material name already taken'}); diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index bb69b3f..eda839e 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -36,16 +36,20 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { if (!data) { res.status(404).json({status: 'Not found'}); } + // add properties needed for conditionIdCheck measurement.measurement_template = data.measurement_template; measurement.condition_id = data.condition_id; if (!await conditionIdCheck(measurement, req, res, next)) return; + + // check for changes if (measurement.values) { measurement.values = _.assign({}, data.values, measurement.values); if (!_.isEqual(measurement.values, data.values)) { - measurement.status = 0; + measurement.status = 0; // set status to new } } + if (!await templateCheck(measurement, 'change', res, next)) return; await MeasurementModel.findByIdAndUpdate(req.params.id, measurement, {new: true}).lean().exec((err, data) => { if (err) return next(err); @@ -99,7 +103,7 @@ async function conditionIdCheck (measurement, req, res, next) { // validate con return true; } -async function templateCheck (measurement, param, res, next) { // validate measurement_template and values +async function templateCheck (measurement, param, res, next) { // validate measurement_template and values, param for new/change const templateData = await MeasurementTemplateModel.findById(measurement.measurement_template).lean().exec().catch(err => {next(err); return false;}) as any; if (!templateData) { // template not found res.status(400).json({status: 'Measurement template not available'}); @@ -108,7 +112,6 @@ async function templateCheck (measurement, param, res, next) { // validate meas // validate values const {error, value: ignore} = ParametersValidate.input(measurement.values, templateData.parameters, param); - console.log(error); if (error) {res400(error, res); return false;} return true; } \ No newline at end of file diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 581ee0c..e1a93d8 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -4,7 +4,6 @@ import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; import TestHelper from "../test/helper"; -// TODO: think again which parameters are required at POST describe('/sample', () => { let server; @@ -23,16 +22,16 @@ describe('/sample', () => { if (err) return done(err); const json = require('../test/db.json'); should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === 10).length); - should(res.body).matchEach(material => { - should(material).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); - should(material).have.property('_id').be.type('string'); - should(material).have.property('number').be.type('string'); - should(material).have.property('type').be.type('string'); - should(material).have.property('color').be.type('string'); - should(material).have.property('batch').be.type('string'); - should(material).have.property('material_id').be.type('string'); - should(material).have.property('note_id'); - should(material).have.property('user_id').be.type('string'); + should(res.body).matchEach(sample => { + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); + should(sample).have.property('_id').be.type('string'); + should(sample).have.property('number').be.type('string'); + should(sample).have.property('type').be.type('string'); + should(sample).have.property('color').be.type('string'); + should(sample).have.property('batch').be.type('string'); + should(sample).have.property('material_id').be.type('string'); + should(sample).have.property('note_id'); + should(sample).have.property('user_id').be.type('string'); }); done(); }); @@ -70,6 +69,94 @@ describe('/sample', () => { }); }); + describe('GET /samples/{group}', () => { + it('returns all new samples', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/new', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + let asyncCounter = res.body.length; + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === 0).length); + should(res.body).matchEach(sample => { + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); + should(sample).have.property('_id').be.type('string'); + should(sample).have.property('number').be.type('string'); + should(sample).have.property('type').be.type('string'); + should(sample).have.property('color').be.type('string'); + should(sample).have.property('batch').be.type('string'); + should(sample).have.property('material_id').be.type('string'); + should(sample).have.property('note_id'); + should(sample).have.property('user_id').be.type('string'); + SampleModel.findById(sample._id).lean().exec((err, data) => { + should(data).have.property('status', 0); + if (--asyncCounter === 0) { + done(); + } + }); + }); + done(); + }); + }); + it('returns all deleted samples', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/deleted', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + let asyncCounter = res.body.length; + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === -1).length); + should(res.body).matchEach(sample => { + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); + should(sample).have.property('_id').be.type('string'); + should(sample).have.property('number').be.type('string'); + should(sample).have.property('type').be.type('string'); + should(sample).have.property('color').be.type('string'); + should(sample).have.property('batch').be.type('string'); + should(sample).have.property('material_id').be.type('string'); + should(sample).have.property('note_id'); + should(sample).have.property('user_id').be.type('string'); + SampleModel.findById(sample._id).lean().exec((err, data) => { + should(data).have.property('status', -1); + if (--asyncCounter === 0) { + done(); + } + }); + }); + done(); + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/new', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/new', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/new', + httpStatus: 401 + }); + }); + }); + describe('PUT /sample/{id}', () => { it('returns the right sample', done => { TestHelper.request(server, done, { @@ -194,12 +281,10 @@ describe('/sample', () => { }).end(err => { if (err) return done(err); NoteFieldModel.findOne({name: 'not allowed for new applications'}).lean().exec((err, data) => { - console.log(data); if (err) return done(err); should(data).have.property('qty', 1); NoteFieldModel.findOne({name: 'field1'}).lean().exec((err, data) => { if (err) return done(err); - console.log(data); should(data).have.property('qty', 1); done(); }); @@ -233,7 +318,6 @@ describe('/sample', () => { if (err) return done (err); NoteModel.findById(res.body.note_id).lean().exec((err, data) => { if (err) return done (err); - console.log(data); should(data).not.be.null(); should(data).have.property('comment', 'Stoff gesperrt'); should(data).have.property('sample_references').have.lengthOf(0); @@ -448,7 +532,6 @@ describe('/sample', () => { setTimeout(() => { // background action takes some time before we can check NoteModel.findById('500000000000000000000003').lean().exec((err, data: any) => { if (err) return done(err); - console.log(data); should(data).have.property('sample_references').with.lengthOf(1); should(data.sample_references[0].id.toString()).be.eql('400000000000000000000003'); should(data.sample_references[0]).have.property('relation', 'part to sample'); diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 38e2282..43acd6e 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -22,6 +22,22 @@ router.get('/samples', (req, res, next) => { }) }); +router.get('/samples/:group(new|deleted)', (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + let status; + switch (req.params.group) { + case 'new': status = 0; + break; + case 'deleted': status = -1; + break; + } + SampleModel.find({status: status}).lean().exec((err, data) => { + if (err) return next(err); + res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors + }) +}); + router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; @@ -33,6 +49,7 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!sampleData) { return res.status(404).json({status: 'Not found'}); } + // only maintain and admin are allowed to edit other user's data if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; @@ -48,12 +65,12 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (sampleData.note_id !== null) { // old notes data exists const data = await NoteModel.findById(sampleData.note_id).lean().exec().catch(err => {next(err);}) as any; if (data instanceof Error) return; - newNotes = !_.isEqual(_.pick(IdValidate.stringify(data), _.keys(sample.notes)), sample.notes); + newNotes = !_.isEqual(_.pick(IdValidate.stringify(data), _.keys(sample.notes)), sample.notes); // check if notes were changed if (newNotes) { if (data.hasOwnProperty('custom_fields')) { // update note_fields customFieldsChange(Object.keys(data.custom_fields), -1); } - NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec(err => { // delete old notes + await NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec(err => { // delete old notes if (err) return console.error(err); }); } @@ -74,7 +91,8 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!_.isEqual(_.pick(IdValidate.stringify(sampleData), _.keys(sample)), _.omit(sample, ['notes']))) { sample.status = 0; } - SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).lean().exec((err, data) => { + + await SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).lean().exec((err, data) => { if (err) return next(err); res.json(SampleValidate.output(data)); }); @@ -90,12 +108,13 @@ router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!sampleData) { return res.status(404).json({status: 'Not found'}); } + // only maintain and admin are allowed to edit other user's data if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; - SampleModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec(err => { // set sample status + await SampleModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec(err => { // set sample status if (err) return next(err); - if (sampleData.note_id !== null) { + if (sampleData.note_id !== null) { // handle notes NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { // find notes to update note_fields if (err) return next(err); if (data.hasOwnProperty('custom_fields')) { // update note_fields @@ -124,15 +143,15 @@ router.post('/sample/new', async (req, res, next) => { customFieldsChange(Object.keys(sample.notes.custom_fields), 1); } - sample.status = 0; + sample.status = 0; // set status to new sample.number = await numberGenerate(sample, req, res, next); if (!sample.number) return; - new NoteModel(sample.notes).save((err, data) => { + + await new NoteModel(sample.notes).save((err, data) => { // save notes if (err) return next(err); delete sample.notes; sample.note_id = data._id; sample.user_id = req.authDetails.id; - console.log(sample); new SampleModel(sample).save((err, data) => { if (err) return next(err); res.json(SampleValidate.output(data.toObject())); @@ -153,7 +172,7 @@ router.get('/sample/notes/fields', (req, res, next) => { module.exports = router; -async function numberGenerate (sample, req, res, next) { // validate number, returns false if invalid +async function numberGenerate (sample, req, res, next) { // generate number, returns false on error const sampleData = await SampleModel .find({number: new RegExp('^' + req.authDetails.location + '[0-9]+$', 'm')}) .lean() @@ -180,7 +199,8 @@ async function materialCheck (sample, res, next, id = sample.material_id) { // function sampleRefCheck (sample, res, next) { // validate sample_references, resolves false for invalid reference return new Promise(resolve => { if (sample.notes.sample_references.length > 0) { // there are sample_references - let referencesCount = sample.notes.sample_references.length; + let referencesCount = sample.notes.sample_references.length; // count to keep track of running async operations + sample.notes.sample_references.forEach(reference => { SampleModel.findById(reference.id).lean().exec((err, data) => { if (err) {next(err); resolve(false)} @@ -189,7 +209,7 @@ function sampleRefCheck (sample, res, next) { // validate sample_references, re return resolve(false); } referencesCount --; - if (referencesCount <= 0) { + if (referencesCount <= 0) { // all async requests done resolve(true); } }); @@ -201,7 +221,7 @@ function sampleRefCheck (sample, res, next) { // validate sample_references, re }); } -function customFieldsChange (fields, amount) { +function customFieldsChange (fields, amount) { // update custom_fields and respective quantities fields.forEach(field => { NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: amount}}, {new: true}).lean().exec((err, data: any) => { // check if field exists if (err) return console.error(err); diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index c2e6fcd..b1a3450 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -200,7 +200,6 @@ describe('/template', () => { httpStatus: 200, req: {parameters: [{name: 'time', range: {type: 'array'}}]} }).end((err, res) => { - console.log(res.body); if (err) return done(err); should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {type: 'array'}}]}); done(); diff --git a/src/routes/template.ts b/src/routes/template.ts index 28caf2b..a8f7413 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -14,7 +14,7 @@ const router = express.Router(); router.get('/template/:collection(measurements|treatments)', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; - req.params.collection = req.params.collection.replace(/s$/g, ''); + req.params.collection = req.params.collection.replace(/s$/g, ''); // remove trailing s model(req).find({}).lean().exec((err, data) => { if (err) next (err); res.json(_.compact(data.map(e => TemplateValidate.output(e, req.params.collection)))); // validate all and filter null values from validation errors @@ -52,8 +52,8 @@ router.put('/template/:collection(measurement|treatment)/' + IdValidate.paramete } if (!_.isEqual(_.pick(templateData, _.keys(template)), template)) { // data was changed - template.version = templateData.version + 1; - await new (model(req))(_.assign({}, _.omit(templateData, ['_id', '__v']), template)).save((err, data) => { + template.version = templateData.version + 1; // increase version + await new (model(req))(_.assign({}, _.omit(templateData, ['_id', '__v']), template)).save((err, data) => { // save new template, fill with old properties if (err) next (err); res.json(TemplateValidate.output(data.toObject(), req.params.collection)); }); @@ -73,7 +73,7 @@ router.post('/template/:collection(measurement|treatment)/new', async (req, res, if (!await numberPrefixCheck(template, req, res, next)) return; } - template.version = 1; + template.version = 1; // set template version await new (model(req))(template).save((err, data) => { if (err) next (err); res.json(TemplateValidate.output(data.toObject(), req.params.collection)); @@ -84,7 +84,7 @@ router.post('/template/:collection(measurement|treatment)/new', async (req, res, module.exports = router; -async function numberPrefixCheck (template, req, res, next) { +async function numberPrefixCheck (template, req, res, next) { // check if number_prefix is available const data = await model(req).findOne({number_prefix: template.number_prefix}).lean().exec().catch(err => {next(err); return false;}) as any; if (data) { res.status(400).json({status: 'Number prefix already taken'}); @@ -93,6 +93,6 @@ async function numberPrefixCheck (template, req, res, next) { return true; } -function model (req) { +function model (req) { // return right template model return req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel; } \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts index 5a2485c..4fb2c0f 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -20,14 +20,10 @@ router.get('/users', (req, res) => { }); router.get('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new. See https://forbeslindesay.github.io/express-route-tester/ for the generated regex - req.params.username = req.params[0]; if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; - let username = req.authDetails.username; - if (req.params.username !== undefined) { - if (!req.auth(res, ['admin'], 'basic')) return; - username = req.params.username; - } + const username = getUsername(req, res); + if (!username) return; UserModel.findOne({name: username}).lean().exec( (err, data:any) => { if (err) return next(err); if (data) { @@ -39,14 +35,13 @@ router.get('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi }); }); -router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new - req.params.username = req.params[0]; +router.put('/user:username([/](?!key|new).?*|/?)', async (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; - let username = req.authDetails.username; - if (req.params.username !== undefined) { - if (!req.auth(res, ['admin'], 'basic')) return; - username = req.params.username; - } + + const username = getUsername(req, res); + if (!username) return; + console.log(username); + const {error, value: user} = UserValidate.input(req.body, 'change' + (req.authDetails.level === 'admin'? 'admin' : '')); if (error) return res400(error, res); @@ -56,45 +51,25 @@ router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi // check that user does not already exist if new name was specified if (user.hasOwnProperty('name') && user.name !== username) { - UserModel.find({name: user.name}).lean().exec( (err, data:any) => { - if (err) return next(err); - if (data.length > 0 || UserValidate.isSpecialName(user.name)) { - res.status(400).json({status: 'Username already taken'}); - return; - } + if (!await usernameCheck(user.name, res, next)) return; + } - UserModel.findOneAndUpdate({name: username}, user, {new: true}).lean().exec( (err, data:any) => { - if (err) return next(err); - if (data) { - res.json(UserValidate.output(data)); - } - else { - res.status(404).json({status: 'Not found'}); - } - }); - }); - } - else { - UserModel.findOneAndUpdate({name: username}, user, {new: true}).lean().exec( (err, data:any) => { - if (err) return next(err); - if (data) { - res.json(UserValidate.output(data)); // validate all and filter null values from validation errors - } - else { - res.status(404).json({status: 'Not found'}); - } - }); - } + await UserModel.findOneAndUpdate({name: username}, user, {new: true}).lean().exec( (err, data:any) => { + if (err) return next(err); + if (data) { + res.json(UserValidate.output(data)); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); }); router.delete('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new. See https://forbeslindesay.github.io/express-route-tester/ for the generated regex - req.params.username = req.params[0]; if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; - let username = req.authDetails.username; - if (req.params.username !== undefined) { - if (!req.auth(res, ['admin'], 'basic')) return; - username = req.params.username; - } + + const username = getUsername(req, res); + if (!username) return; UserModel.findOneAndDelete({name: username}).lean().exec( (err, data:any) => { if (err) return next(err); @@ -116,7 +91,7 @@ router.get('/user/key', (req, res, next) => { }); }); -router.post('/user/new', (req, res, next) => { +router.post('/user/new', async (req, res, next) => { if (!req.auth(res, ['admin'], 'basic')) return; // validate input @@ -124,20 +99,14 @@ router.post('/user/new', (req, res, next) => { if (error) return res400(error, res); // check that user does not already exist - UserModel.find({name: user.name}).lean().exec( (err, data:any) => { - if (err) return next(err); - if (data.length > 0 || UserValidate.isSpecialName(user.name)) { - res.status(400).json({status: 'Username already taken'}); - return; - } + if (!await usernameCheck(user.name, res, next)) 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) return next(err); - res.json(UserValidate.output(data.toObject())); - }); + 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) return next(err); + res.json(UserValidate.output(data.toObject())); }); }); }); @@ -147,11 +116,14 @@ router.post('/user/passreset', (req, res, next) => { UserModel.find({name: req.body.name, email: req.body.email}).lean().exec( (err, data: any) => { if (err) return next(err); if (data.length === 1) { // it exists - const newPass = Math.random().toString(36).substring(2); + const newPass = Math.random().toString(36).substring(2); // generate temporary password bcrypt.hash(newPass, 10, (err, hash) => { // password hashing if (err) return next(err); + UserModel.findByIdAndUpdate(data[0]._id, {pass: hash}, err => { // write new password if (err) return next(err); + + // send email mail(data[0].email, 'Your new password for the DFOP database', 'Hi,

    You requested to reset your password.
    Your new password is:

    ' + newPass + '

    If you did not request a password reset, talk to the sysadmin quickly!

    Have a nice day.

    The DFOP team', err => { if (err) return next(err); res.json({status: 'OK'}); @@ -166,4 +138,27 @@ router.post('/user/passreset', (req, res, next) => { }); -module.exports = router; \ No newline at end of file +module.exports = router; + +function getUsername (req, res) { // returns username or false if action is not allowed + req.params.username = req.params[0]; // because of path regex + if (req.params.username !== undefined) { // different username than request user + if (!req.auth(res, ['admin'], 'basic')) return false; + return req.params.username; + } + else { + return req.authDetails.username; + } +} + +async function usernameCheck (name, res, next) { // check if username is already taken + const userData = await UserModel.findOne({name: name}).lean().exec().catch(err => next(err)) as any; + if (userData instanceof Error) return false; + console.log(userData); + console.log(UserValidate.isSpecialName(name)); + if (userData || UserValidate.isSpecialName(name)) { + res.status(400).json({status: 'Username already taken'}); + return false; + } + return true; +} \ No newline at end of file diff --git a/src/routes/validate/condition.ts b/src/routes/validate/condition.ts index 491c318..d752ff3 100644 --- a/src/routes/validate/condition.ts +++ b/src/routes/validate/condition.ts @@ -18,7 +18,7 @@ export default class ConditionValidate { ) } - static input (data, param) { + static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { return Joi.object({ sample_id: IdValidate.get().required(), @@ -36,7 +36,7 @@ export default class ConditionValidate { } } - static output (data) { + static output (data) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); const {value, error} = Joi.object({ _id: IdValidate.get(), diff --git a/src/routes/validate/id.ts b/src/routes/validate/id.ts index a9bb70a..6b7b677 100644 --- a/src/routes/validate/id.ts +++ b/src/routes/validate/id.ts @@ -3,11 +3,11 @@ import Joi from '@hapi/joi'; export default class IdValidate { private static id = Joi.string().pattern(new RegExp('[0-9a-f]{24}')).length(24); - static get () { + static get () { // return joi validation return this.id; } - static valid (id) { + static valid (id) { // validate id return this.id.validate(id).error === undefined; } @@ -15,11 +15,14 @@ export default class IdValidate { return ':id([0-9a-f]{24})'; } - static stringify (data) { + static stringify (data) { // convert all ObjectID objects to plain strings Object.keys(data).forEach(key => { - if (data[key] !== null && data[key].hasOwnProperty('_bsontype') && data[key]._bsontype === 'ObjectID') { + if (data[key] !== null && data[key].hasOwnProperty('_bsontype') && data[key]._bsontype === 'ObjectID') { // stringify id data[key] = data[key].toString(); } + else if (typeof data[key] === 'object' && data[key] !== null) { // deeper into recursion + data[key] = this.stringify(data[key]); + } }); return data; } diff --git a/src/routes/validate/material.ts b/src/routes/validate/material.ts index b09c494..c92f440 100644 --- a/src/routes/validate/material.ts +++ b/src/routes/validate/material.ts @@ -40,7 +40,7 @@ export default class MaterialValidate { // validate input for material })) }; - static input (data, param) { // validate data, param: new(everything required)/change(available attributes are validated) + static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { return joi.object({ name: this.material.name.required(), @@ -68,7 +68,7 @@ export default class MaterialValidate { // validate input for material } } - static output (data) { // validate output from database for needed properties, strip everything else + static output (data) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); const {value, error} = joi.object({ _id: IdValidate.get(), diff --git a/src/routes/validate/measurement.ts b/src/routes/validate/measurement.ts index 0efaaea..21b38a2 100644 --- a/src/routes/validate/measurement.ts +++ b/src/routes/validate/measurement.ts @@ -15,7 +15,7 @@ export default class MeasurementValidate { ) }; - static input (data, param) { + static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { return Joi.object({ condition_id: IdValidate.get().required(), @@ -33,7 +33,7 @@ export default class MeasurementValidate { } } - static output (data) { + static output (data) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); const {value, error} = Joi.object({ _id: IdValidate.get(), diff --git a/src/routes/validate/note_field.ts b/src/routes/validate/note_field.ts index 7d34d98..68856c9 100644 --- a/src/routes/validate/note_field.ts +++ b/src/routes/validate/note_field.ts @@ -8,7 +8,7 @@ export default class NoteFieldValidate { qty: Joi.number() }; - static output (data) { + static output (data) { // validate output and strip unwanted properties, returns null if not valid const {value, error} = Joi.object({ name: this.note_field.name, qty: this.note_field.qty diff --git a/src/routes/validate/parameters.ts b/src/routes/validate/parameters.ts index d855815..79e62ef 100644 --- a/src/routes/validate/parameters.ts +++ b/src/routes/validate/parameters.ts @@ -4,7 +4,7 @@ export default class ParametersValidate { static input (data, parameters, param) { // data to validate, parameters from template, param: 'new', 'change' let joiObject = {}; parameters.forEach(parameter => { - if (parameter.range.hasOwnProperty('values')) { + if (parameter.range.hasOwnProperty('values')) { // append right validation method according to parameter joiObject[parameter.name] = Joi.alternatives() .try(Joi.string().max(128), Joi.number(), Joi.boolean()) .valid(...parameter.range.values); diff --git a/src/routes/validate/res400.ts b/src/routes/validate/res400.ts index 5e032f7..e4595c8 100644 --- a/src/routes/validate/res400.ts +++ b/src/routes/validate/res400.ts @@ -1,3 +1,5 @@ +// respond with 400 and include error details from the joi validation + export default function res400 (error, res) { res.status(400).json({status: 'Invalid body format', details: error.details[0].message}); } \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 9373152..1b23cb1 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -41,7 +41,7 @@ export default class SampleValidate { }) }; - static input (data, param) { // validate data, param: new(everything required)/change(available attributes are validated) + static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { return Joi.object({ color: this.sample.color.required(), @@ -65,7 +65,7 @@ export default class SampleValidate { } } - static output (data) { + static output (data) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); const {value, error} = Joi.object({ _id: IdValidate.get(), diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts index 269e1a2..571f48c 100644 --- a/src/routes/validate/template.ts +++ b/src/routes/validate/template.ts @@ -43,7 +43,7 @@ export default class TemplateValidate { ) }; - static input (data, param, template) { // validate data, param: new(everything required)/change(available attributes are validated) + static input (data, param, template) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { if (template === 'treatment') { return Joi.object({ @@ -79,10 +79,10 @@ export default class TemplateValidate { } } - static output (data, template) { // validate output from database for needed properties, strip everything else + static output (data, template) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); let joiObject; - if (template === 'treatment') { + if (template === 'treatment') { // differentiate between measurement and treatment (has number_prefix) template joiObject = { _id: IdValidate.get(), name: this.template.name, diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index c146d7e..0c073d0 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -33,7 +33,7 @@ export default class UserValidate { // validate input for user private static specialUsernames = ['admin', 'user', 'key', 'new', 'passreset']; // names a user cannot take - static input (data, param) { + static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { return Joi.object({ name: this.user.name.required(), @@ -68,7 +68,7 @@ export default class UserValidate { // validate input for user } } - static output (data) { // validate output from database for needed properties, strip everything else + static output (data) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); const {value, error} = Joi.object({ _id: IdValidate.get(), diff --git a/src/test/helper.ts b/src/test/helper.ts index b7ff49f..3983959 100644 --- a/src/test/helper.ts +++ b/src/test/helper.ts @@ -4,13 +4,13 @@ import db from "../db"; export default class TestHelper { - public static auth = { + public static auth = { // test user credentials admin: {pass: 'Abc123!#', key: '000000000000000000001003'}, janedoe: {pass: 'Xyz890*)', key: '000000000000000000001002'}, user: {pass: 'Xyz890*)', key: '000000000000000000001001'}, johnnydoe: {pass: 'Xyz890*)', key: '000000000000000000001004'} } - public static res = { + public static res = { // default responses 400: {status: 'Bad request'}, 401: {status: 'Unauthorized'}, 403: {status: 'Forbidden'}, @@ -40,10 +40,10 @@ export default class TestHelper { static request (server, done, options) { // options in form: {method, url, auth: {key/basic: 'name' or 'key'/{name, pass}}, httpStatus, req, res} let st = supertest(server); - if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('key')) { + if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('key')) { // resolve API key options.url += '?key=' + (this.auth.hasOwnProperty(options.auth.key)? this.auth[options.auth.key].key : options.auth.key); } - switch (options.method) { + switch (options.method) { // http method case 'get': st = st.get(options.url) break; @@ -57,10 +57,10 @@ export default class TestHelper { st = st.delete(options.url) break; } - if (options.hasOwnProperty('req')) { + if (options.hasOwnProperty('req')) { // request body st = st.send(options.req); } - if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('basic')) { + if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('basic')) { // resolve basic auth if (this.auth.hasOwnProperty(options.auth.basic)) { st = st.auth(options.auth.basic, this.auth[options.auth.basic].pass) } @@ -70,21 +70,21 @@ export default class TestHelper { } st = st.expect('Content-type', /json/) .expect(options.httpStatus); - if (options.hasOwnProperty('res')) { + if (options.hasOwnProperty('res')) { // evaluate result return st.end((err, res) => { if (err) return done (err); should(res.body).be.eql(options.res); done(); }); } - else if (this.res.hasOwnProperty(options.httpStatus) && options.default !== false) { + else if (this.res.hasOwnProperty(options.httpStatus) && options.default !== false) { // evaluate default results return st.end((err, res) => { if (err) return done (err); should(res.body).be.eql(this.res[options.httpStatus]); done(); }); } - else { + else { // return object to do .end() manually return st; } } diff --git a/src/test/loadDev.ts b/src/test/loadDev.ts index 690044d..15a6868 100644 --- a/src/test/loadDev.ts +++ b/src/test/loadDev.ts @@ -1,5 +1,7 @@ import db from '../db'; +// script to load test db into dev db for a clean start + db.connect('dev', () => { console.info('dropping data...'); db.drop(() => { // reset database From aef275322955774e0c34c69ce3c4e7722aadcb9e Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Tue, 26 May 2020 09:07:01 +0200 Subject: [PATCH 42/83] added TODOs, improved password validation --- src/api.ts | 2 +- src/index.ts | 23 ++++++++++++++--------- src/routes/condition.spec.ts | 4 ++++ src/routes/material.spec.ts | 2 ++ src/routes/measurement.spec.ts | 3 +++ src/routes/sample.spec.ts | 3 +++ src/routes/template.spec.ts | 1 + src/routes/user.spec.ts | 1 + src/routes/validate/user.ts | 2 +- 9 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/api.ts b/src/api.ts index 625e738..59ce0b3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -4,7 +4,7 @@ import oasParser from '@apidevtools/swagger-parser'; // modifies the normal swagger-ui-express package -// usage: app.use('/api', api.serve(), api.setup()); +// usage: app.use('/api-doc', api.serve(), api.setup()); // the paths property can be split using allOf // further route documentation can be included in the x-doc property diff --git a/src/index.ts b/src/index.ts index 362f5cb..4ce0581 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,12 @@ import mongoSanitize from 'mongo-sanitize'; import api from './api'; import db from './db'; - +// TODO: changelog +// TODO: check executing index.js/move everything needed into dist +// TODO: One condition per sample +// TODO: validation: VZ, Humidity: min/max value, DPT: filename +// TODO: condition values not needed on initial add +// TODO: add multiple samples at once // tell if server is running in debug or production environment console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); @@ -44,19 +49,19 @@ app.use((req, res, next) => { // no database connection error app.use(require('./helpers/authorize')); // handle authentication // require routes -app.use('/', require('./routes/root')); -app.use('/', require('./routes/sample')); -app.use('/', require('./routes/material')); -app.use('/', require('./routes/template')); -app.use('/', require('./routes/user')); -app.use('/', require('./routes/condition')); -app.use('/', require('./routes/measurement')); +app.use('/api', require('./routes/root')); +app.use('/api', require('./routes/sample')); +app.use('/api', require('./routes/material')); +app.use('/api', require('./routes/template')); +app.use('/api', require('./routes/user')); +app.use('/api', require('./routes/condition')); +app.use('/api', require('./routes/measurement')); // static files app.use('/static', express.static('static')); // Swagger UI -app.use('/api', api.serve(), api.setup()); +app.use('/api-doc', api.serve(), api.setup()); app.use((req, res) => { // 404 error handling res.status(404).json({status: 'Not found'}); diff --git a/src/routes/condition.spec.ts b/src/routes/condition.spec.ts index ef01c89..90c7c43 100644 --- a/src/routes/condition.spec.ts +++ b/src/routes/condition.spec.ts @@ -2,6 +2,10 @@ import should from 'should/as-function'; import ConditionModel from '../models/condition'; import TestHelper from "../test/helper"; +// TODO: adding conditions allowed only for m/a +// TODO: deleted data only visible for m/a +// TODO: restore deleted +// TODO: remove number_prefix describe('/condition', () => { let server; diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index df58310..21a278b 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -3,6 +3,8 @@ import _ from 'lodash'; import MaterialModel from '../models/material'; import TestHelper from "../test/helper"; +// TODO: color name must be unique to get color number +// TODO: separate supplier/ material name into own collections describe('/material', () => { let server; diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index 7a604d2..7fe4b7f 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -2,6 +2,9 @@ import should from 'should/as-function'; import MeasurementModel from '../models/measurement'; import TestHelper from "../test/helper"; +// TODO: allow empty values + + describe('/measurement', () => { let server; before(done => TestHelper.before(done)); diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index e1a93d8..df1ad05 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -4,6 +4,9 @@ import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; import TestHelper from "../test/helper"; +// TODO: generate output for ML in format DPT -> data, implement filtering, field selection +// TODO: write script for data import +// TODO: delete everything (measurements, condition) with sample describe('/sample', () => { let server; diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index b1a3450..878b778 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -4,6 +4,7 @@ import TemplateTreatmentModel from '../models/treatment_template'; import TemplateMeasurementModel from '../models/measurement_template'; import TestHelper from "../test/helper"; +// TODO: do not allow usage of old templates for new samples describe('/template', () => { let server; diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index e294cb2..6a7d69e 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -2,6 +2,7 @@ import should from 'should/as-function'; import UserModel from '../models/user'; import TestHelper from "../test/helper"; +// TODO: reject usernames containing admin, etc. describe('/user', () => { let server; diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index 0c073d0..bd4dfbd 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -16,7 +16,7 @@ export default class UserValidate { // validate input for user .max(128), pass: Joi.string() - .pattern(new RegExp('^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$')) + .pattern(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&'()*+,-.\/:;<=>?@[\]^_`{|}~])(?=\S+$)[a-zA-Z0-9!"#%&'()*+,\-.\/:;<=>?@[\]^_`{|}~]{8,}$/) .max(128), level: Joi.string() From 0acb9dd6fce4784a7720c07458d0c9937eba965c Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Wed, 27 May 2020 14:31:17 +0200 Subject: [PATCH 43/83] adapted existing /sample methods to condition, removed /condition route --- .idea/dictionaries/VLE2FE.xml | 2 + .idea/libraries/dist.xml | 13 - api/api.yaml | 1 - api/condition.yaml | 111 -- api/sample.yaml | 4 +- api/schemas.yaml | 39 +- api/template.yaml | 34 +- package-lock.json | 976 ++++++++++++++++++ package.json | 5 +- src/globals.ts | 8 +- src/index.ts | 21 +- src/models/condition.ts | 13 - ...ment_template.ts => condition_template.ts} | 5 +- src/models/measurement.ts | 6 +- src/models/note.ts | 2 +- src/models/sample.ts | 3 +- src/routes/condition.spec.ts | 583 ----------- src/routes/condition.ts | 133 --- src/routes/material.spec.ts | 21 +- src/routes/material.ts | 18 +- src/routes/measurement.spec.ts | 9 +- src/routes/measurement.ts | 20 +- src/routes/sample.spec.ts | 436 ++++++-- src/routes/sample.ts | 90 +- src/routes/template.spec.ts | 197 ++-- src/routes/template.ts | 46 +- src/routes/user.ts | 3 - src/routes/validate/condition.ts | 50 - src/routes/validate/material.ts | 41 +- src/routes/validate/sample.ts | 54 +- src/routes/validate/template.ts | 68 +- src/routes/validate/user.ts | 4 + src/test/db.json | 110 +- src/test/helper.ts | 1 + 34 files changed, 1753 insertions(+), 1374 deletions(-) delete mode 100644 .idea/libraries/dist.xml delete mode 100644 api/condition.yaml delete mode 100644 src/models/condition.ts rename src/models/{treatment_template.ts => condition_template.ts} (59%) delete mode 100644 src/routes/condition.spec.ts delete mode 100644 src/routes/condition.ts delete mode 100644 src/routes/validate/condition.ts diff --git a/.idea/dictionaries/VLE2FE.xml b/.idea/dictionaries/VLE2FE.xml index c274b8b..1dd7309 100644 --- a/.idea/dictionaries/VLE2FE.xml +++ b/.idea/dictionaries/VLE2FE.xml @@ -4,6 +4,8 @@ bcrypt cfenv dfopdb + janedoe + testcomment \ No newline at end of file diff --git a/.idea/libraries/dist.xml b/.idea/libraries/dist.xml deleted file mode 100644 index 3d92275..0000000 --- a/.idea/libraries/dist.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/api/api.yaml b/api/api.yaml index f890477..c0a5441 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -66,7 +66,6 @@ paths: - $ref: 'others.yaml' - $ref: 'sample.yaml' - $ref: 'material.yaml' - - $ref: 'condition.yaml' - $ref: 'measurement.yaml' - $ref: 'template.yaml' - $ref: 'model.yaml' diff --git a/api/condition.yaml b/api/condition.yaml deleted file mode 100644 index ec8b245..0000000 --- a/api/condition.yaml +++ /dev/null @@ -1,111 +0,0 @@ -/condition/{id}: - parameters: - - $ref: 'api.yaml#/components/parameters/Id' - get: - summary: condition by id - description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: status handling (accessible (only for maintain/admin))? # TODO after decision - tags: - - /condition - responses: - 200: - description: condition details - content: - application/json: - schema: - $ref: 'api.yaml#/components/schemas/Condition' - 400: - $ref: 'api.yaml#/components/responses/400' - 401: - $ref: 'api.yaml#/components/responses/401' - 404: - $ref: 'api.yaml#/components/responses/404' - 500: - $ref: 'api.yaml#/components/responses/500' - put: - summary: change condition - description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to reference samples created by another user' - x-doc: status is reset to 0 on any changes - tags: - - /condition - security: - - BasicAuth: [] - requestBody: - required: true - content: - application/json: - schema: - allOf: - - $ref: 'api.yaml#/components/schemas/_Id' - properties: - parameters: - type: object - responses: - 200: - description: condition details - content: - application/json: - schema: - $ref: 'api.yaml#/components/schemas/Condition' - 400: - $ref: 'api.yaml#/components/responses/400' - 401: - $ref: 'api.yaml#/components/responses/401' - 403: - $ref: 'api.yaml#/components/responses/403' - 404: - $ref: 'api.yaml#/components/responses/404' - 500: - $ref: 'api.yaml#/components/responses/500' - delete: - summary: delete condition - description: 'Auth: basic, levels: write, maintain, dev, admin' - x-doc: sets status to -1 - tags: - - /condition - security: - - BasicAuth: [] - responses: - 200: - $ref: 'api.yaml#/components/responses/Ok' - 400: - $ref: 'api.yaml#/components/responses/400' - 401: - $ref: 'api.yaml#/components/responses/401' - 403: - $ref: 'api.yaml#/components/responses/403' - 404: - $ref: 'api.yaml#/components/responses/404' - 500: - $ref: 'api.yaml#/components/responses/500' - -/condition/new: - post: - summary: add condition - description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to reference samples created by another user' - x-doc: 'Adds status: 0 automatically' - tags: - - /condition - security: - - BasicAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: 'api.yaml#/components/schemas/Condition' - responses: - 200: - description: condition details - content: - application/json: - schema: - $ref: 'api.yaml#/components/schemas/Condition' - 400: - $ref: 'api.yaml#/components/responses/400' - 401: - $ref: 'api.yaml#/components/responses/401' - 403: - $ref: 'api.yaml#/components/responses/403' - 500: - $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/api/sample.yaml b/api/sample.yaml index c699809..9e830ff 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -19,7 +19,7 @@ 500: $ref: 'api.yaml#/components/responses/500' -/samples{group}: +/samples/{group}: parameters: - $ref: 'api.yaml#/components/parameters/Group' get: @@ -48,7 +48,7 @@ get: summary: TODO sample details description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: status handling (accessible (only for maintain/admin))? # TODO after decision + x-doc: deleted samples are available only for maintain/admin tags: - /sample responses: diff --git a/api/schemas.yaml b/api/schemas.yaml index c872443..6e1eeb7 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -24,6 +24,15 @@ SampleProperties: batch: type: string example: 1560237365 + condition: + type: object + properties: + condition_template: + $ref: 'api.yaml#/components/schemas/Id' + example: + condition_template: 5ea0450ed851c30a90e70894 + material: hot air + weeks: 5 SampleRefs: allOf: @@ -55,7 +64,7 @@ Sample: type: array items: properties: - id: + sample_id: $ref: 'api.yaml#/components/schemas/Id' relation: type: string @@ -67,7 +76,8 @@ SampleDetail: - $ref: 'api.yaml#/components/schemas/SampleProperties' properties: material: - $ref: 'api.yaml#/components/schemas/Material' + allOf: + - $ref: 'api.yaml#/components/schemas/Material' notes: type: object properties: @@ -77,10 +87,14 @@ SampleDetail: type: array items: $ref: 'api.yaml#/components/schemas/Id' - conditions: + measurements: type: array items: - $ref: 'api.yaml#/components/schemas/Condition' + allOf: + - $ref: 'api.yaml#/components/schemas/Measurement' + user: + type: string + example: admin Material: allOf: @@ -115,21 +129,6 @@ Material: type: string example: 5514263423 -Condition: - allOf: - - $ref: 'api.yaml#/components/schemas/_Id' - properties: - sample_id: - $ref: 'api.yaml#/components/schemas/Id' - number: - type: string - readOnly: true - example: B1 - parameters: - type: object - treatment_template: - $ref: 'api.yaml#/components/schemas/Id' - Measurement: allOf: - $ref: 'api.yaml#/components/schemas/_Id' @@ -166,7 +165,7 @@ Template: min: 0 max: 2 -TreatmentTemplate: +ConditionTemplate: allOf: - $ref: 'api.yaml#/components/schemas/Template' properties: diff --git a/api/template.yaml b/api/template.yaml index 37f374a..71a282f 100644 --- a/api/template.yaml +++ b/api/template.yaml @@ -1,6 +1,6 @@ -/template/treatments: +/template/conditions: get: - summary: all available treatment methods + summary: all available condition methods description: 'Auth: basic, levels: read, write, maintain, dev, admin' tags: - /template @@ -8,23 +8,23 @@ - BasicAuth: [] responses: 200: - description: list of treatments + description: list of conditions content: application/json: schema: type: array items: - $ref: 'api.yaml#/components/schemas/TreatmentTemplate' + $ref: 'api.yaml#/components/schemas/ConditionTemplate' 401: $ref: 'api.yaml#/components/responses/401' 500: $ref: 'api.yaml#/components/responses/500' -/template/treatment/{id}: +/template/condition/{id}: parameters: - $ref: 'api.yaml#/components/parameters/Id' get: - summary: treatment method details + summary: condition method details description: 'Auth: basic, levels: read, write, maintain, admin' tags: - /template @@ -32,11 +32,11 @@ - BasicAuth: [] responses: 200: - description: treatment details + description: condition details content: application/json: schema: - $ref: 'api.yaml#/components/schemas/TreatmentTemplate' + $ref: 'api.yaml#/components/schemas/ConditionTemplate' 401: $ref: 'api.yaml#/components/responses/401' 404: @@ -44,7 +44,7 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: change treatment method + summary: change condition method description: 'Auth: basic, levels: maintain, admin' x-doc: With a change a new version is set, resulting in a new template with a new id tags: @@ -56,14 +56,14 @@ content: application/json: schema: - $ref: 'api.yaml#/components/schemas/TreatmentTemplate' + $ref: 'api.yaml#/components/schemas/ConditionTemplate' responses: 200: - description: treatment details + description: condition details content: application/json: schema: - $ref: 'api.yaml#/components/schemas/TreatmentTemplate' + $ref: 'api.yaml#/components/schemas/ConditionTemplate' 400: $ref: 'api.yaml#/components/responses/400' 401: @@ -75,9 +75,9 @@ 500: $ref: 'api.yaml#/components/responses/500' -/template/treatment/new: +/template/condition/new: post: - summary: add treatment method + summary: add condition method description: 'Auth: basic, levels: maintain, admin' tags: - /template @@ -88,14 +88,14 @@ content: application/json: schema: - $ref: 'api.yaml#/components/schemas/TreatmentTemplate' + $ref: 'api.yaml#/components/schemas/ConditionTemplate' responses: 200: - description: treatment details + description: condition details content: application/json: schema: - $ref: 'api.yaml#/components/schemas/TreatmentTemplate' + $ref: 'api.yaml#/components/schemas/ConditionTemplate' 400: $ref: 'api.yaml#/components/responses/400' 401: diff --git a/package-lock.json b/package-lock.json index 4c3c77d..6d935ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,169 @@ "@babel/highlight": "^7.8.3" } }, + "@babel/core": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.9.6.tgz", + "integrity": "sha512-nD3deLvbsApbHAHttzIssYqgb883yU/d9roe4RZymBCDaZryMJDbptVpEpeQuRh4BJ+SYI8le9YGxKvFEvl1Wg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.6", + "@babel/helper-module-transforms": "^7.9.0", + "@babel/helpers": "^7.9.6", + "@babel/parser": "^7.9.6", + "@babel/template": "^7.8.6", + "@babel/traverse": "^7.9.6", + "@babel/types": "^7.9.6", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.13", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.6.tgz", + "integrity": "sha512-+htwWKJbH2bL72HRluF8zumBxzuX0ZZUFl3JLNyoUjM/Ho8wnVpPXM6aUz8cfKDqQ/h7zHqKt4xzJteUosckqQ==", + "dev": true, + "requires": { + "@babel/types": "^7.9.6", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.9.5.tgz", + "integrity": "sha512-JVcQZeXM59Cd1qanDUxv9fgJpt3NeKUaqBqUEvfmQ+BCOKq2xUgaWZW2hr0dkbyJgezYuplEoh5knmrnS68efw==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.9.5" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz", + "integrity": "sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-module-imports": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz", + "integrity": "sha512-R0Bx3jippsbAEtzkpZ/6FIiuzOURPcMjHp+Z6xPe6DtApDJx+w7UYyOLanZqO8+wKR9G10s/FmHXvxaMd9s6Kg==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-module-transforms": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.9.0.tgz", + "integrity": "sha512-0FvKyu0gpPfIQ8EkxlrAydOWROdHpBmiCiRwLkUiBGhCUPRRbVD2/tm3sFr/c/GWFrQ/ffutGUAnx7V0FzT2wA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.8.3", + "@babel/helper-replace-supers": "^7.8.6", + "@babel/helper-simple-access": "^7.8.3", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/template": "^7.8.6", + "@babel/types": "^7.9.0", + "lodash": "^4.17.13" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz", + "integrity": "sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-replace-supers": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.9.6.tgz", + "integrity": "sha512-qX+chbxkbArLyCImk3bWV+jB5gTNU/rsze+JlcF6Nf8tVTigPJSI1o1oBow/9Resa1yehUO9lIipsmu9oG4RzA==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.8.3", + "@babel/helper-optimise-call-expression": "^7.8.3", + "@babel/traverse": "^7.9.6", + "@babel/types": "^7.9.6" + } + }, + "@babel/helper-simple-access": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.8.3.tgz", + "integrity": "sha512-VNGUDjx5cCWg4vvCTR8qQ7YJYZ+HBjxOgXEl7ounz+4Sn7+LMD3CFrCTEU6/qXKbA2nKg21CwhhBzO0RpRbdCw==", + "dev": true, + "requires": { + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz", + "integrity": "sha512-/8arLKUFq882w4tWGj9JYzRpAlZgiWUJ+dtteNTDqrRBz9Iguck9Rn3ykuBDoUwh2TO4tSAJlrxDUOXWklJe4g==", + "dev": true + }, + "@babel/helpers": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.9.6.tgz", + "integrity": "sha512-tI4bUbldloLcHWoRUMAj4g1bF313M/o6fBKhIsb3QnGVPwRm9JsNf/gqMkQ7zjqReABiffPV6RWj7hEglID5Iw==", + "dev": true, + "requires": { + "@babel/template": "^7.8.3", + "@babel/traverse": "^7.9.6", + "@babel/types": "^7.9.6" + } + }, "@babel/highlight": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.8.3.tgz", @@ -56,6 +219,68 @@ "js-tokens": "^4.0.0" } }, + "@babel/parser": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.6.tgz", + "integrity": "sha512-AoeIEJn8vt+d/6+PXDRPaksYhnlbMIiejioBZvvMQsOjW/JYK6k/0dKnvvP3EhK5GfMBWDPtrxRtegWdAcdq9Q==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/traverse": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.6.tgz", + "integrity": "sha512-b3rAHSjbxy6VEAvlxM8OV/0X4XrG72zoxme6q1MOoe2vd0bEc+TwayhuC1+Dfgqh1QEG+pj7atQqvUprHIccsg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.6", + "@babel/helper-function-name": "^7.9.5", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/types": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.6.tgz", + "integrity": "sha512-qxXzvBO//jO9ZnoasKF1uJzHd2+M6Q2ZPIVfnFps8JJvXy0ZBbwbNOmE6SGIY5XOY6d1Bo5lb9d9RJ8nv3WSeA==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.5", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, "@hapi/address": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-4.0.1.tgz", @@ -99,6 +324,67 @@ "@hapi/hoek": "^9.0.0" } }, + "@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@istanbuljs/schema": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz", + "integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==", + "dev": true + }, "@jsdevtools/ono": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.2.tgz", @@ -233,6 +519,16 @@ "negotiator": "0.6.2" } }, + "aggregate-error": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz", + "integrity": "sha512-quoaXsZ9/BLNae5yiNoUz+Nhkwz83GhWwtYFglcjEQB2NDHCIpApbqXxIFnm4Pq/Nvhrsq5sYJFyohrrxnTGAA==", + "dev": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + } + }, "ansi-align": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz", @@ -295,6 +591,21 @@ "picomatch": "^2.0.4" } }, + "append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "requires": { + "default-require-extensions": "^3.0.0" + } + }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -528,6 +839,18 @@ } } }, + "caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "requires": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + } + }, "call-me-maybe": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", @@ -578,6 +901,12 @@ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==" }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true + }, "cli-boxes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.0.tgz", @@ -657,6 +986,12 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", @@ -699,6 +1034,15 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" }, + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, "cookie": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", @@ -721,6 +1065,28 @@ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "dependencies": { + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", @@ -753,6 +1119,15 @@ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" }, + "default-require-extensions": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.0.tgz", + "integrity": "sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==", + "dev": true, + "requires": { + "strip-bom": "^4.0.0" + } + }, "defer-to-connect": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", @@ -854,6 +1229,12 @@ "is-symbol": "^1.0.2" } }, + "es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, "escape-goat": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", @@ -949,6 +1330,17 @@ "unpipe": "~1.0.0" } }, + "find-cache-dir": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz", + "integrity": "sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, "find-up": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", @@ -985,6 +1377,16 @@ } } }, + "foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + } + }, "form-data": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", @@ -1012,6 +1414,12 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" }, + "fromentries": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.2.0.tgz", + "integrity": "sha512-33X7H/wdfO99GdRLLgkjUrD4geAFdq/Uv0kl3HD4da6HDixd2GUg8Mw7dahLCV9r/EARkmtYBB6Tch4EEokFTQ==", + "dev": true + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1029,12 +1437,24 @@ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, + "gensync": { + "version": "1.0.0-beta.1", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz", + "integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==", + "dev": true + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true + }, "get-stream": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", @@ -1072,6 +1492,12 @@ "ini": "^1.3.5" } }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, "got": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", @@ -1126,12 +1552,28 @@ "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==" }, + "hasha": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.0.tgz", + "integrity": "sha512-2W+jKdQbAdSIrggA8Q35Br8qKadTrqCTC8+XZvBWepKDK6m9XkX6Iz1a2yh2KP01kzAR/dpuMeUnocoLYDcskw==", + "dev": true, + "requires": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + } + }, "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", @@ -1172,6 +1614,12 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -1286,6 +1734,12 @@ "has": "^1.0.3" } }, + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true + }, "is-symbol": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", @@ -1300,6 +1754,12 @@ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, "is-yarn-global": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz", @@ -1317,6 +1777,128 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, + "istanbul-lib-coverage": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", + "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==", + "dev": true + }, + "istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "requires": { + "append-transform": "^2.0.0" + } + }, + "istanbul-lib-instrument": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", + "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, + "requires": { + "@babel/core": "^7.7.5", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "istanbul-lib-processinfo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz", + "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==", + "dev": true, + "requires": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.0", + "istanbul-lib-coverage": "^3.0.0-alpha.1", + "make-dir": "^3.0.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^3.3.3" + } + }, + "istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz", + "integrity": "sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.0.2.tgz", + "integrity": "sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1331,6 +1913,12 @@ "esprima": "^4.0.0" } }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, "json-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", @@ -1341,6 +1929,15 @@ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.5.tgz", "integrity": "sha512-gWJOWYFrhQ8j7pVm0EM8Slr+EPVq1Phf6lvzvD/WCeqkrx/f2xBI0xOsRRS9xCn3I4vKtP519dvs3TP09r24wQ==" }, + "json5": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", + "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, "kareem": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.1.tgz", @@ -1377,6 +1974,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, + "lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", + "dev": true + }, "lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -1677,6 +2280,15 @@ "semver": "^5.7.0" } }, + "node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "requires": { + "process-on-spawn": "^1.0.0" + } + }, "nodemon": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.3.tgz", @@ -1727,6 +2339,196 @@ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==" }, + "nyc": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.0.1.tgz", + "integrity": "sha512-n0MBXYBYRqa67IVt62qW1r/d9UH/Qtr7SF1w/nQLJ9KxvWF6b2xCHImRAixHN9tnMMYHC2P14uo6KddNGwMgGg==", + "dev": true, + "requires": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^4.0.0", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "yargs": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.3.1.tgz", + "integrity": "sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA==", + "dev": true, + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.1" + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, "object-inspect": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", @@ -1805,12 +2607,33 @@ "p-limit": "^2.0.0" } }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, "p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true }, + "package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + } + }, "package-json": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", @@ -1845,6 +2668,12 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, "path-parse": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", @@ -1860,6 +2689,51 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.1.tgz", "integrity": "sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA==" }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + } + } + }, "ports": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/ports/-/ports-1.1.0.tgz", @@ -1876,6 +2750,15 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "requires": { + "fromentries": "^1.2.0" + } + }, "proxy-addr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.5.tgz", @@ -1983,6 +2866,15 @@ "rc": "^1.2.8" } }, + "release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=", + "dev": true, + "requires": { + "es6-error": "^4.0.1" + } + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2025,6 +2917,15 @@ "lowercase-keys": "^1.0.0" } }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -2113,6 +3014,21 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, "should": { "version": "13.2.3", "resolved": "https://registry.npmjs.org/should/-/should-13.2.3.tgz", @@ -2182,6 +3098,12 @@ "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", "integrity": "sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E=" }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, "sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -2191,6 +3113,31 @@ "memory-pager": "^1.0.2" } }, + "spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "requires": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "dependencies": { + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -2271,6 +3218,12 @@ "ansi-regex": "^3.0.0" } }, + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true + }, "strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -2347,6 +3300,23 @@ "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.0.tgz", "integrity": "sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw==" }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, "to-readable-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", @@ -2544,6 +3514,12 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + }, "validator": { "version": "12.2.0", "resolved": "https://registry.npmjs.org/validator/-/validator-12.2.0.tgz", diff --git a/package.json b/package.json index 4ec763a..5763fdc 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,12 @@ "main": "index.js", "scripts": { "tsc": "tsc", + "tsc-full": "del /q dist\\* & (for /d %x in (dist\\*) do @rd /s /q \"%x\") & tsc", "test": "mocha dist/**/**.spec.js", "start": "tsc && node dist/index.js || exit 1", "dev": "nodemon -e ts,yaml --exec \"npm run start\"", - "loadDev": "node dist/test/loadDev.js" + "loadDev": "node dist/test/loadDev.js", + "coverage": "nyc --reporter=html --reporter=tex mocha dist/**/**.spec.js" }, "keywords": [], "author": "", @@ -44,6 +46,7 @@ "devDependencies": { "@types/lodash": "^4.14.150", "mocha": "^7.1.2", + "nyc": "^15.0.1", "should": "^13.2.3", "supertest": "^4.0.2" } diff --git a/src/globals.ts b/src/globals.ts index 0d4ccdb..81f80b8 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -5,7 +5,13 @@ const globals = { 'maintain', 'dev', 'admin' - ] + ], + + status: { // document statuses + deleted: -1, + new: 0, + validated: 10, + } }; export default globals; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 4ce0581..1343442 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import db from './db'; // TODO: validation: VZ, Humidity: min/max value, DPT: filename // TODO: condition values not needed on initial add // TODO: add multiple samples at once +// TODO: coverage // tell if server is running in debug or production environment console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); @@ -48,14 +49,20 @@ app.use((req, res, next) => { // no database connection error }); app.use(require('./helpers/authorize')); // handle authentication +// redirect /api routes for Angular proxy in development +app.use('/api/:url', (req, res) => { + req.url = '/' + req.params.url; + app.handle(req, res); +}); + + // require routes -app.use('/api', require('./routes/root')); -app.use('/api', require('./routes/sample')); -app.use('/api', require('./routes/material')); -app.use('/api', require('./routes/template')); -app.use('/api', require('./routes/user')); -app.use('/api', require('./routes/condition')); -app.use('/api', require('./routes/measurement')); +app.use('/', require('./routes/root')); +app.use('/', require('./routes/sample')); +app.use('/', require('./routes/material')); +app.use('/', require('./routes/template')); +app.use('/', require('./routes/user')); +app.use('/', require('./routes/measurement')); // static files app.use('/static', express.static('static')); diff --git a/src/models/condition.ts b/src/models/condition.ts deleted file mode 100644 index e0f79da..0000000 --- a/src/models/condition.ts +++ /dev/null @@ -1,13 +0,0 @@ -import mongoose from 'mongoose'; -import SampleModel from './sample'; -import TreatmentTemplateModel from './treatment_template'; - -const ConditionSchema = new mongoose.Schema({ - sample_id: {type: mongoose.Schema.Types.ObjectId, ref: SampleModel}, - number: String, - parameters: mongoose.Schema.Types.Mixed, - treatment_template: {type: mongoose.Schema.Types.ObjectId, ref: TreatmentTemplateModel}, - status: Number -}); - -export default mongoose.model('condition', ConditionSchema); \ No newline at end of file diff --git a/src/models/treatment_template.ts b/src/models/condition_template.ts similarity index 59% rename from src/models/treatment_template.ts rename to src/models/condition_template.ts index 154ae79..20c7234 100644 --- a/src/models/treatment_template.ts +++ b/src/models/condition_template.ts @@ -1,13 +1,12 @@ import mongoose from 'mongoose'; -const TreatmentTemplateSchema = new mongoose.Schema({ +const ConditionTemplateSchema = new mongoose.Schema({ name: String, version: Number, - number_prefix: String, parameters: [{ name: String, range: mongoose.Schema.Types.Mixed }] }, {minimize: false}); // to allow empty objects -export default mongoose.model('treatment_template', TreatmentTemplateSchema); \ No newline at end of file +export default mongoose.model('condition_template', ConditionTemplateSchema); \ No newline at end of file diff --git a/src/models/measurement.ts b/src/models/measurement.ts index ac0ef20..7db0a50 100644 --- a/src/models/measurement.ts +++ b/src/models/measurement.ts @@ -1,9 +1,11 @@ import mongoose from 'mongoose'; -import ConditionModel from './condition'; +import SampleModel from './sample'; import MeasurementTemplateModel from './measurement_template'; +// TODO: change to sample_id + const MeasurementSchema = new mongoose.Schema({ - condition_id: {type: mongoose.Schema.Types.ObjectId, ref: ConditionModel}, + sample_id: {type: mongoose.Schema.Types.ObjectId, ref: SampleModel}, values: mongoose.Schema.Types.Mixed, measurement_template: {type: mongoose.Schema.Types.ObjectId, ref: MeasurementTemplateModel}, status: Number diff --git a/src/models/note.ts b/src/models/note.ts index a13fd6a..cd0847b 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -3,7 +3,7 @@ import mongoose from 'mongoose'; const NoteSchema = new mongoose.Schema({ comment: String, sample_references: [{ - id: mongoose.Schema.Types.ObjectId, + sample_id: mongoose.Schema.Types.ObjectId, relation: String }], custom_fields: mongoose.Schema.Types.Mixed diff --git a/src/models/sample.ts b/src/models/sample.ts index 9e5353b..1338728 100644 --- a/src/models/sample.ts +++ b/src/models/sample.ts @@ -9,10 +9,11 @@ const SampleSchema = new mongoose.Schema({ type: String, color: String, batch: String, + condition: mongoose.Schema.Types.Mixed, material_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialModel}, note_id: {type: mongoose.Schema.Types.ObjectId, ref: NoteModel}, user_id: {type: mongoose.Schema.Types.ObjectId, ref: UserModel}, status: Number -}); +}, {minimize: false}); export default mongoose.model('sample', SampleSchema); \ No newline at end of file diff --git a/src/routes/condition.spec.ts b/src/routes/condition.spec.ts deleted file mode 100644 index 90c7c43..0000000 --- a/src/routes/condition.spec.ts +++ /dev/null @@ -1,583 +0,0 @@ -import should from 'should/as-function'; -import ConditionModel from '../models/condition'; -import TestHelper from "../test/helper"; - -// TODO: adding conditions allowed only for m/a -// TODO: deleted data only visible for m/a -// TODO: restore deleted -// TODO: remove number_prefix - -describe('/condition', () => { - let server; - before(done => TestHelper.before(done)); - beforeEach(done => server = TestHelper.beforeEach(server, done)); - afterEach(done => TestHelper.afterEach(server, done)); - - describe('GET /condition/{id}', () => { - it('returns the right condition', done => { - TestHelper.request(server, done, { - method: 'get', - url: '/condition/700000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 200, - res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', parameters: {material: 'copper', weeks: 3}, treatment_template: '200000000000000000000001'} - }); - }); - it('returns the right condition for an API key', done => { - TestHelper.request(server, done, { - method: 'get', - url: '/condition/700000000000000000000001', - auth: {key: 'janedoe'}, - httpStatus: 200, - res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', parameters: {material: 'copper', weeks: 3}, treatment_template: '200000000000000000000001'} - }); - }); - it('rejects an invalid id', done => { - TestHelper.request(server, done, { - method: 'get', - url: '/condition/70000000000t000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 404 - }); - }); - it('rejects an unknown id', done => { - TestHelper.request(server, done, { - method: 'get', - url: '/condition/000000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 404 - }); - }); - it('rejects unauthorized requests', done => { - TestHelper.request(server, done, { - method: 'get', - url: '/condition/700000000000000000000001', - httpStatus: 401 - }); - }); - }); - - describe('PUT /condition{id}', () => { - it('returns the right condition', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 200, - req: {}, - res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}} - }); - }); - it('keeps unchanged properties', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 200, - req: {parameters: {material: 'copper', weeks: 3}} - }).end((err, res) => { - if (err) return done(err); - should(res.body).be.eql({_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}}); - ConditionModel.findById('700000000000000000000001').lean().exec((err, data) => { - if (err) return done(err); - should(data).have.property('status', 10); - done(); - }); - }); - }); - it('keeps only one unchanged parameter', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 200, - req: {parameters: {material: 'copper'}} - }).end((err, res) => { - if (err) return done(err); - should(res.body).be.eql({_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 3}}); - ConditionModel.findById('700000000000000000000001').lean().exec((err, data) => { - if (err) return done(err); - should(data).have.property('status', 10); - done(); - }); - }); - }); - it('changes the given properties', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 200, - req: {parameters: {material: 'hot air', weeks: 10}} - }).end((err, res) => { - if (err) return done(err); - should(res.body).be.eql({_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', treatment_template: '200000000000000000000001', parameters: {material: 'hot air', weeks: 10}}); - ConditionModel.findById('700000000000000000000001').lean().exec((err, data: any) => { - if (err) return done(err); - should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', 'status', '__v'); - should(data.sample_id.toString()).be.eql('400000000000000000000001'); - should(data).have.property('number', 'A1'); - should(data.treatment_template.toString()).be.eql('200000000000000000000001'); - should(data).have.property('status', 0); - should(data).have.property('parameters'); - should(data.parameters).have.property('material', 'hot air'); - should(data.parameters).have.property('weeks', 10); - done(); - }); - }); - }); - it('allows changing only one parameter', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 200, - req: {parameters: {weeks: 8}}, - res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', treatment_template: '200000000000000000000001', parameters: {material: 'copper', weeks: 8}} - }); - }); - it('rejects changing the condition number', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {number: 'C2'}, - res: {status: 'Invalid body format', details: '"number" is not allowed'} - }); - }); - it('rejects not specified parameters', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {parameters: {xx: 13}}, - res: {status: 'Invalid body format', details: '"xx" is not allowed'} - }); - }); - it('rejects a parameter not in the value range', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {parameters: {material: 'xxx'}}, - res: {status: 'Invalid body format', details: '"material" must be one of [copper, hot air]'} - }); - }); - it('rejects a parameter below minimum range', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {parameters: {weeks: -10}}, - res: {status: 'Invalid body format', details: '"weeks" must be larger than or equal to 1'} - }); - }); - it('rejects a parameter above maximum range', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {parameters: {weeks: 11}}, - res: {status: 'Invalid body format', details: '"weeks" must be less than or equal to 10'} - }); - }); - it('rejects a new treatment_template', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {treatment_template: '200000000000000000000002'}, - res: {status: 'Invalid body format', details: '"treatment_template" is not allowed'} - }); - }); - it('rejects editing a condition for a write user who did not create this condition', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000003', - auth: {basic: 'janedoe'}, - httpStatus: 403, - req: {parameters: {weeks: 8}} - }); - }); - it('accepts editing a condition of another user for a maintain/admin user', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {basic: 'admin'}, - httpStatus: 200, - req: {parameters: {material: 'hot air', weeks: 10}}, - res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'A1', treatment_template: '200000000000000000000001', parameters: {material: 'hot air', weeks: 10}} - }); - }); - it('rejects an API key', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {key: 'janedoe'}, - httpStatus: 401, - req: {parameters: {material: 'hot air', weeks: 10}} - }); - }); - it('rejects requests from a read user', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - auth: {basic: 'user'}, - httpStatus: 403, - req: {parameters: {material: 'hot air', weeks: 10}} - }); - }); - it('rejects unauthorized requests', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/condition/700000000000000000000001', - httpStatus: 401, - req: {parameters: {material: 'hot air', weeks: 10}} - }); - }); - }); - - describe('DELETE /condition/{id}', () => { - it('sets the status to deleted', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/condition/700000000000000000000004', - auth: {basic: 'janedoe'}, - httpStatus: 200 - }).end((err, res) => { - if (err) return done(err); - should(res.body).be.eql({status: 'OK'}); - ConditionModel.findById('700000000000000000000004').lean().exec((err, data: any) => { - if (err) return done(err); - should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', 'status', '__v'); - should(data.sample_id.toString()).be.eql('400000000000000000000001'); - should(data).have.property('number', 'A2'); - should(data.treatment_template.toString()).be.eql('200000000000000000000001'); - should(data).have.property('status', -1); - should(data).have.property('parameters'); - should(data.parameters).have.property('material', 'hot air'); - should(data.parameters).have.property('weeks', 5); - done(); - }); - }); - }); - it('rejects deleting a condition referenced by measurements'/*, done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/condition/700000000000000000000002', - auth: {basic: 'janedoe'}, - httpStatus: 200, - res: {status: 'Condition still in use'} - }); - }*/); // TODO after decision - it('rejects an invalid id', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/condition/70000000000w000000000002', - auth: {basic: 'janedoe'}, - httpStatus: 404 - }); - }); - it('rejects an API key', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/condition/700000000000000000000004', - auth: {key: 'janedoe'}, - httpStatus: 401 - }); - }); - it('rejects requests from a read user', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/condition/700000000000000000000004', - auth: {basic: 'user'}, - httpStatus: 403 - }); - }); - it('rejects a write user deleting a condition belonging to a sample of another user', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/condition/700000000000000000000003', - auth: {basic: 'janedoe'}, - httpStatus: 403 - }); - }); - it('accepts an maintain/admin user deleting a condition belonging to a sample of another user', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/condition/700000000000000000000004', - auth: {basic: 'admin'}, - httpStatus: 200 - }).end((err, res) => { - if (err) return done(err); - should(res.body).be.eql({status: 'OK'}); - done(); - }); - }); - it('returns 404 for an unknown id', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/condition/000000000000000000000002', - auth: {basic: 'janedoe'}, - httpStatus: 404 - }); - }); - it('rejects unauthorized requests', done => { - TestHelper.request(server, done, { - method: 'delete', - url: '/condition/700000000000000000000004', - httpStatus: 401 - }); - }); - }); - - describe('POST /condition/new', () => { - it('returns the right condition', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 200, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} - }).end((err, res) => { - if (err) return done(err); - should(res.body).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template'); - should(res.body).have.property('_id').be.type('string'); - should(res.body).have.property('sample_id', '400000000000000000000002'); - should(res.body).have.property('number', 'A2'); - should(res.body).have.property('treatment_template', '200000000000000000000001'); - should(res.body).have.property('parameters'); - should(res.body.parameters).have.property('material', 'hot air'); - should(res.body.parameters).have.property('weeks', 10); - done(); - }); - }); - it('stores the condition', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 200, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} - }).end((err, res) => { - if (err) return done(err); - ConditionModel.findById(res.body._id).lean().exec((err, data: any) => { - if (err) return done(err); - should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', 'status', '__v'); - should(data.sample_id.toString()).be.eql('400000000000000000000002'); - should(data).have.property('number', 'A2'); - should(data.treatment_template.toString()).be.eql('200000000000000000000001'); - should(data).have.property('status', 0); - should(data).have.property('parameters'); - should(data.parameters).have.property('material', 'hot air'); - should(data.parameters).have.property('weeks', 10); - done(); - }); - }); - }); - it('stores the first condition as 1', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'admin'}, - httpStatus: 200, - req: {sample_id: '400000000000000000000003', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} - }).end((err, res) => { - if (err) return done(err); - ConditionModel.findById(res.body._id).lean().exec((err, data: any) => { - if (err) return done(err); - should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', 'status', '__v'); - should(data.sample_id.toString()).be.eql('400000000000000000000003'); - should(data).have.property('number', 'A1'); - should(data.treatment_template.toString()).be.eql('200000000000000000000001'); - should(data).have.property('status', 0); - should(data).have.property('parameters'); - should(data.parameters).have.property('material', 'hot air'); - should(data.parameters).have.property('weeks', 10); - done(); - }); - }); - }); - it('rejects an invalid sample id', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {sample_id: '4000000000h0000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, - res: {status: 'Invalid body format', details: '"sample_id" with value "4000000000h0000000000002" fails to match the required pattern: /[0-9a-f]{24}/'} - }); - }); - it('rejects a sample id not available', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {sample_id: '000000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, - res: {status: 'Sample id not available'} - }); - }); - it('rejects an invalid treatment_template id', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000h00000000001'}, - res: {status: 'Invalid body format', details: '"treatment_template" with value "200000000000h00000000001" fails to match the required pattern: /[0-9a-f]{24}/'} - }); - }); - it('rejects a treatment_template which does not exist', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '000000000000000000000001'}, - res: {status: 'Treatment template not available'} - }); - }); - it('rejects setting a condition number', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {sample_id: '400000000000000000000001', number: 'A7', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, - res: {status: 'Invalid body format', details: '"number" is not allowed'} - }); - }); - it('rejects not specified parameters', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10, xx: 12}, treatment_template: '200000000000000000000001'}, - res: {status: 'Invalid body format', details: '"xx" is not allowed'} - }); - }); - it('rejects missing parameters', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air'}, treatment_template: '200000000000000000000001'}, - res: {status: 'Invalid body format', details: '"weeks" is required'} - }); - }); - it('rejects a parameter not in the value range', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {sample_id: '400000000000000000000002', parameters: {material: 'xxx', weeks: 10}, treatment_template: '200000000000000000000001'}, - res: {status: 'Invalid body format', details: '"material" must be one of [copper, hot air]'} - }); - }); - it('rejects a parameter below minimum range', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: -10}, treatment_template: '200000000000000000000001'}, - res: {status: 'Invalid body format', details: '"weeks" must be larger than or equal to 1'} - }); - }); - it('rejects a parameter above maximum range', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 11}, treatment_template: '200000000000000000000001'}, - res: {status: 'Invalid body format', details: '"weeks" must be less than or equal to 10'} - }); - }); - it('rejects a missing sample id', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, - res: {status: 'Invalid body format', details: '"sample_id" is required'} - }); - }); - it('rejects a missing treatment_template', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}}, - res: {status: 'Invalid body format', details: '"treatment_template" is required'} - }); - }); - it('rejects adding a condition to the sample of an other user for a write user', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'janedoe'}, - httpStatus: 403, - req: {sample_id: '400000000000000000000003', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} - }); - }); - it('accepts adding a condition to the sample of an other user for a maintain/admin user', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'admin'}, - httpStatus: 200, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} - }).end((err, res) => { - if (err) return done(err); - should(res.body).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template'); - should(res.body).have.property('_id').be.type('string'); - should(res.body).have.property('sample_id', '400000000000000000000002'); - should(res.body).have.property('number', 'A2'); - should(res.body).have.property('treatment_template', '200000000000000000000001'); - should(res.body).have.property('parameters'); - should(res.body.parameters).have.property('material', 'hot air'); - should(res.body.parameters).have.property('weeks', 10); - done(); - }); - }); - it('rejects an API key', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {key: 'janedoe'}, - httpStatus: 401, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} - }); - }); - it('rejects requests from a read user', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - auth: {basic: 'user'}, - httpStatus: 403, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} - }); - }); - it('rejects unauthorized requests', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/condition/new', - httpStatus: 401, - req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} - }); - }); - }); -}); \ No newline at end of file diff --git a/src/routes/condition.ts b/src/routes/condition.ts deleted file mode 100644 index f66d10a..0000000 --- a/src/routes/condition.ts +++ /dev/null @@ -1,133 +0,0 @@ -import express from 'express'; -import _ from 'lodash'; - -import ConditionValidate from './validate/condition'; -import ParametersValidate from './validate/parameters'; -import res400 from './validate/res400'; -import SampleModel from '../models/sample'; -import ConditionModel from '../models/condition'; -import TreatmentTemplateModel from '../models/treatment_template'; -import IdValidate from './validate/id'; - - -const router = express.Router(); - -router.get('/condition/' + IdValidate.parameter(), (req, res, next) => { - if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - - ConditionModel.findById(req.params.id).lean().exec((err, data) => { - if (err) return next(err); - if (data) { - res.json(ConditionValidate.output(data)); - } - else { - res.status(404).json({status: 'Not found'}); - } - }); -}); - -router.put('/condition/' + IdValidate.parameter(), async (req, res, next) => { - if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; - - const {error, value: condition} = ConditionValidate.input(req.body, 'change'); - if (error) return res400(error, res); - - const data = await ConditionModel.findById(req.params.id).lean().exec().catch(err => {next(err);}) as any; - if (data instanceof Error) return; - if (!data) { - res.status(404).json({status: 'Not found'}); - } - - // add properties needed for sampleIdCheck - condition.treatment_template = data.treatment_template; - condition.sample_id = data.sample_id; - if (!await sampleIdCheck(condition, req, res, next)) return; - if (condition.parameters) { - condition.parameters = _.assign({}, data.parameters, condition.parameters); - if (!_.isEqual(condition.parameters, data.parameters)) { // parameters did not change - condition.status = 0; - } - } - if (!await treatmentCheck(condition, 'change', res, next)) return; - - await ConditionModel.findByIdAndUpdate(req.params.id, condition, {new: true}).lean().exec((err, data) => { - if (err) return next(err); - res.json(ConditionValidate.output(data)); - }); -}); - -router.delete('/condition/' + IdValidate.parameter(), (req, res, next) => { - if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; - - ConditionModel.findById(req.params.id).lean().exec(async (err, data: any) => { - if (err) return next(err); - if (!data) { - res.status(404).json({status: 'Not found'}); - } - if (!await sampleIdCheck(data, req, res, next)) return; - await ConditionModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec(err => { - if (err) return next(err); - res.json({status: 'OK'}); - }); - }); -}); - -router.post('/condition/new', async (req, res, next) => { - if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; - - const {error, value: condition} = ConditionValidate.input(req.body, 'new'); - if (error) return res400(error, res); - - if (!await sampleIdCheck(condition, req, res, next)) return; - const treatmentData = await treatmentCheck(condition, 'new', res, next) - if (!treatmentData) return; - - condition.number = await numberGenerate(condition, treatmentData, next); - if (!condition.number) return; - condition.status = 0; // set status to new - await new ConditionModel(condition).save((err, data) => { - if (err) return next(err); - res.json(ConditionValidate.output(data.toObject())); - }); -}) - - -module.exports = router; - - -async function sampleIdCheck (condition, req, res, next) { // validate sample_id, returns false if invalid - const sampleData = await SampleModel.findById(condition.sample_id).lean().exec().catch(err => {next(err); return false;}) as any; - if (!sampleData) { // sample_id not found - res.status(400).json({status: 'Sample id not available'}); - return false - } - - if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return false; // sample does not belong to user - return true; -} - -async function numberGenerate (condition, treatmentData, next) { // generate number, returns false on error - const conditionData = await ConditionModel // find condition with highest number belonging to the same sample - .find({sample_id: condition.sample_id, number: new RegExp('^' + treatmentData.number_prefix + '[0-9]+$', 'm')}) - .sort({number: -1}) - .limit(1) - .lean() - .exec() - .catch(err => next(err)) as any; - if (conditionData instanceof Error) return false; - return treatmentData.number_prefix + (conditionData.length > 0 ? Number(conditionData[0].number.replace(/[^0-9]+/g, '')) + 1 : 1); // return new number -} - -async function treatmentCheck (condition, param, res, next) { // validate treatment template, returns false if invalid, otherwise template data - const treatmentData = await TreatmentTemplateModel.findById(condition.treatment_template).lean().exec().catch(err => next(err)) as any; - if (treatmentData instanceof Error) return false; - if (!treatmentData) { // template not found - res.status(400).json({status: 'Treatment template not available'}); - return false; - } - - // validate parameters - const {error, value: ignore} = ParametersValidate.input(condition.parameters, treatmentData.parameters, param); - if (error) {res400(error, res); return false;} - return treatmentData; -} \ No newline at end of file diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 21a278b..344642d 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -2,6 +2,7 @@ import should from 'should/as-function'; import _ from 'lodash'; import MaterialModel from '../models/material'; import TestHelper from "../test/helper"; +import globals from '../globals'; // TODO: color name must be unique to get color number // TODO: separate supplier/ material name into own collections @@ -22,7 +23,7 @@ describe('/material', () => { }).end((err, res) => { if (err) return done(err); const json = require('../test/db.json'); - should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === 10).length); + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === globals.status.validated).length); should(res.body).matchEach(material => { should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); should(material).have.property('_id').be.type('string'); @@ -50,7 +51,7 @@ describe('/material', () => { }).end((err, res) => { if (err) return done(err); const json = require('../test/db.json'); - should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === 10).length); + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === globals.status.validated).length); should(res.body).matchEach(material => { should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); should(material).have.property('_id').be.type('string'); @@ -89,7 +90,7 @@ describe('/material', () => { if (err) return done(err); const json = require('../test/db.json'); let asyncCounter = res.body.length; - should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === 0).length); + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status ===globals.status.new).length); should(res.body).matchEach(material => { should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); should(material).have.property('_id').be.type('string'); @@ -105,7 +106,7 @@ describe('/material', () => { should(number).have.property('number').be.type('string'); }); MaterialModel.findById(material._id).lean().exec((err, data) => { - should(data).have.property('status', 0); + should(data).have.property('status',globals.status.new); if (--asyncCounter === 0) { done(); } @@ -123,7 +124,7 @@ describe('/material', () => { if (err) return done(err); const json = require('../test/db.json'); let asyncCounter = res.body.length; - should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === -1).length); + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status ===globals.status.deleted).length); should(res.body).matchEach(material => { should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); should(material).have.property('_id').be.type('string'); @@ -139,7 +140,7 @@ describe('/material', () => { should(number).have.property('number').be.type('string'); }); MaterialModel.findById(material._id).lean().exec((err, data) => { - should(data).have.property('status', -1); + should(data).have.property('status',globals.status.deleted); if (--asyncCounter === 0) { done(); } @@ -249,7 +250,7 @@ describe('/material', () => { should(res.body).be.eql({_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]}); MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { if (err) return done(err); - should(data).have.property('status', 10); + should(data).have.property('status',globals.status.validated); done(); }); }); @@ -266,7 +267,7 @@ describe('/material', () => { should(res.body).be.eql({_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]}); MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { if (err) return done(err); - should(data).have.property('status', 10); + should(data).have.property('status',globals.status.validated); done(); }); }); @@ -513,7 +514,7 @@ describe('/material', () => { should(data[0]).have.property('mineral', '0'); should(data[0]).have.property('glass_fiber', '30'); should(data[0]).have.property('carbon_fiber', '0'); - should(data[0]).have.property('status', 0); + should(data[0]).have.property('status',globals.status.new); should(data[0].numbers).have.lengthOf(0); done(); }); @@ -552,7 +553,7 @@ describe('/material', () => { should(data[0]).have.property('mineral', '0'); should(data[0]).have.property('glass_fiber', '30'); should(data[0]).have.property('carbon_fiber', '0'); - should(data[0]).have.property('status', 0); + should(data[0]).have.property('status',globals.status.new); should(_.omit(data[0].numbers[0], '_id')).be.eql({color: 'black', number: ''}); done(); }); diff --git a/src/routes/material.ts b/src/routes/material.ts index dd89985..4a1adb8 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -7,6 +7,7 @@ import SampleModel from '../models/sample'; import IdValidate from './validate/id'; import res400 from './validate/res400'; import mongoose from 'mongoose'; +import globals from '../globals'; @@ -15,7 +16,7 @@ const router = express.Router(); router.get('/materials', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - MaterialModel.find({status: 10}).lean().exec((err, data) => { + MaterialModel.find({status:globals.status.validated}).lean().exec((err, data) => { if (err) return next(err); res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors }); @@ -24,14 +25,7 @@ router.get('/materials', (req, res, next) => { router.get('/materials/:group(new|deleted)', (req, res, next) => { if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; - let status; - switch (req.params.group) { - case 'new': status = 0; - break; - case 'deleted': status = -1; - break; - } - MaterialModel.find({status: status}).lean().exec((err, data) => { + MaterialModel.find({status: globals.status[req.params.group]}).lean().exec((err, data) => { if (err) return next(err); res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors }); @@ -67,7 +61,7 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { // check for changes if (!_.isEqual(_.pick(IdValidate.stringify(materialData), _.keys(material)), material)) { - material.status = 0; // set status to new + material.status = globals.status.new; // set status to new } await MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).lean().exec((err, data) => { @@ -86,7 +80,7 @@ router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { if (data.length) { return res.status(400).json({status: 'Material still in use'}); } - MaterialModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec((err, data) => { + MaterialModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).lean().exec((err, data) => { if (err) return next(err); if (data) { res.json({status: 'OK'}); @@ -106,7 +100,7 @@ router.post('/material/new', async (req, res, next) => { if (!await nameCheck(material, res, next)) return; - material.status = 0; // set status to new + material.status = globals.status.new; // set status to new await new MaterialModel(material).save((err, data) => { if (err) return next(err); res.json(MaterialValidate.output(data.toObject())); diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index 7fe4b7f..8ca49ed 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -1,6 +1,7 @@ import should from 'should/as-function'; import MeasurementModel from '../models/measurement'; import TestHelper from "../test/helper"; +import globals from '../globals'; // TODO: allow empty values @@ -78,7 +79,7 @@ describe('/measurement', () => { should(res.body).be.eql({_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'}); MeasurementModel.findById('800000000000000000000001').lean().exec((err, data: any) => { if (err) return done(err); - should(data).have.property('status', 10); + should(data).have.property('status',globals.status.validated); done(); }); }); @@ -95,7 +96,7 @@ describe('/measurement', () => { should(res.body).be.eql({_id: '800000000000000000000002', condition_id: '700000000000000000000002', values: {'weight %': 0.5, 'standard deviation': 0.2}, measurement_template: '300000000000000000000002'}); MeasurementModel.findById('800000000000000000000002').lean().exec((err, data: any) => { if (err) return done(err); - should(data).have.property('status', 10); + should(data).have.property('status',globals.status.validated); done(); }); }); @@ -114,7 +115,7 @@ describe('/measurement', () => { should(data).have.only.keys('_id', 'condition_id', 'values', 'measurement_template', 'status', '__v'); should(data.condition_id.toString()).be.eql('700000000000000000000001'); should(data.measurement_template.toString()).be.eql('300000000000000000000001'); - should(data).have.property('status', 0); + should(data).have.property('status',globals.status.new); should(data).have.property('values'); should(data.values).have.property('dpt', [[1,2],[3,4],[5,6]]); done(); @@ -256,7 +257,7 @@ describe('/measurement', () => { should(res.body).be.eql({status: 'OK'}); MeasurementModel.findById('800000000000000000000001').lean().exec((err, data) => { if (err) return done(err); - should(data).have.property('status', -1); + should(data).have.property('status',globals.status.deleted); done(); }); }); diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index eda839e..b9af125 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -2,12 +2,12 @@ import express from 'express'; import _ from 'lodash'; import MeasurementModel from '../models/measurement'; -import ConditionModel from '../models/condition'; import MeasurementTemplateModel from '../models/measurement_template'; import MeasurementValidate from './validate/measurement'; import IdValidate from './validate/id'; import res400 from './validate/res400'; import ParametersValidate from './validate/parameters'; +import globals from '../globals'; const router = express.Router(); @@ -46,7 +46,7 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { if (measurement.values) { measurement.values = _.assign({}, data.values, measurement.values); if (!_.isEqual(measurement.values, data.values)) { - measurement.status = 0; // set status to new + measurement.status = globals.status.new; // set status to new } } @@ -66,7 +66,7 @@ router.delete('/measurement/' + IdValidate.parameter(), (req, res, next) => { res.status(404).json({status: 'Not found'}); } if (!await conditionIdCheck(data, req, res, next)) return; - await MeasurementModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec(err => { + await MeasurementModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).lean().exec(err => { if (err) return next(err); res.json({status: 'OK'}); }); @@ -93,14 +93,14 @@ router.post('/measurement/new', async (req, res, next) => { module.exports = router; -async function conditionIdCheck (measurement, req, res, next) { // validate condition_id, returns false if invalid - const sampleData = await ConditionModel.findById(measurement.condition_id).populate('sample_id').lean().exec().catch(err => {next(err); return false;}) as any; - if (!sampleData) { // sample_id not found - res.status(400).json({status: 'Condition id not available'}); +async function conditionIdCheck (measurement, req, res, next) { // validate condition_id, returns false if invalid // TODO + // const sampleData = await ConditionModel.findById(measurement.condition_id).populate('sample_id').lean().exec().catch(err => {next(err); return false;}) as any; + // if (!sampleData) { // sample_id not found + // res.status(400).json({status: 'Condition id not available'}); return false - } - if (sampleData.sample_id.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return false; // sample does not belong to user - return true; + // } + // if (sampleData.sample_id.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return false; // sample does not belong to user + // return true; } async function templateCheck (measurement, param, res, next) { // validate measurement_template and values, param for new/change diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index df1ad05..f54f5b4 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -3,10 +3,16 @@ import SampleModel from '../models/sample'; import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; import TestHelper from "../test/helper"; +import globals from '../globals'; // TODO: generate output for ML in format DPT -> data, implement filtering, field selection +// TODO: filter by not completely filled/no measurements // TODO: write script for data import // TODO: delete everything (measurements, condition) with sample +// TODO: allow adding sample numbers for existing samples + +// TODO: Do not allow validation or measurement entry without condition + describe('/sample', () => { let server; @@ -24,14 +30,16 @@ describe('/sample', () => { }).end((err, res) => { if (err) return done(err); const json = require('../test/db.json'); - should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === 10).length); + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.validated).length); should(res.body).matchEach(sample => { - should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); should(sample).have.property('_id').be.type('string'); should(sample).have.property('number').be.type('string'); should(sample).have.property('type').be.type('string'); should(sample).have.property('color').be.type('string'); should(sample).have.property('batch').be.type('string'); + should(sample).have.property('condition').be.type('object'); + should(sample.condition).have.property('condition_template').be.type('string'); should(sample).have.property('material_id').be.type('string'); should(sample).have.property('note_id'); should(sample).have.property('user_id').be.type('string'); @@ -48,17 +56,19 @@ describe('/sample', () => { }).end((err, res) => { if (err) return done(err); const json = require('../test/db.json'); - should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === 10).length); - should(res.body).matchEach(material => { - should(material).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); - should(material).have.property('_id').be.type('string'); - should(material).have.property('number').be.type('string'); - should(material).have.property('type').be.type('string'); - should(material).have.property('color').be.type('string'); - should(material).have.property('batch').be.type('string'); - should(material).have.property('material_id').be.type('string'); - should(material).have.property('note_id'); - should(material).have.property('user_id').be.type('string'); + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.validated).length); + should(res.body).matchEach(sample => { + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); + should(sample).have.property('_id').be.type('string'); + should(sample).have.property('number').be.type('string'); + should(sample).have.property('type').be.type('string'); + should(sample).have.property('color').be.type('string'); + should(sample).have.property('batch').be.type('string'); + should(sample).have.property('condition').be.type('object'); + should(sample.condition).have.property('condition_template').be.type('string'); + should(sample).have.property('material_id').be.type('string'); + should(sample).have.property('note_id'); + should(sample).have.property('user_id').be.type('string'); }); done(); }); @@ -83,25 +93,28 @@ describe('/sample', () => { if (err) return done(err); const json = require('../test/db.json'); let asyncCounter = res.body.length; - should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === 0).length); + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.new).length); should(res.body).matchEach(sample => { - should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); should(sample).have.property('_id').be.type('string'); should(sample).have.property('number').be.type('string'); should(sample).have.property('type').be.type('string'); should(sample).have.property('color').be.type('string'); should(sample).have.property('batch').be.type('string'); + should(sample).have.property('condition').be.type('object'); + if (Object.keys(sample.condition).length > 0) { + should(sample.condition).have.property('condition_template').be.type('string'); + } should(sample).have.property('material_id').be.type('string'); should(sample).have.property('note_id'); should(sample).have.property('user_id').be.type('string'); SampleModel.findById(sample._id).lean().exec((err, data) => { - should(data).have.property('status', 0); + should(data).have.property('status',globals.status.new); if (--asyncCounter === 0) { done(); } }); }); - done(); }); }); it('returns all deleted samples', done => { @@ -116,23 +129,26 @@ describe('/sample', () => { let asyncCounter = res.body.length; should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === -1).length); should(res.body).matchEach(sample => { - should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); should(sample).have.property('_id').be.type('string'); should(sample).have.property('number').be.type('string'); should(sample).have.property('type').be.type('string'); should(sample).have.property('color').be.type('string'); should(sample).have.property('batch').be.type('string'); + should(sample).have.property('condition').be.type('object'); + should(sample.condition).have.property('condition_template').be.type('string'); + should(sample.condition).have.property('condition_template').be.type('string'); + should(sample.condition).have.property('condition_template').be.type('string'); should(sample).have.property('material_id').be.type('string'); should(sample).have.property('note_id'); should(sample).have.property('user_id').be.type('string'); SampleModel.findById(sample._id).lean().exec((err, data) => { - should(data).have.property('status', -1); + should(data).have.property('status',globals.status.deleted); if (--asyncCounter === 0) { done(); } }); }); - done(); }); }); it('rejects requests from a write user', done => { @@ -160,6 +176,73 @@ describe('/sample', () => { }); }); + describe('GET /sample/{id}', () => { + it('returns the right sample', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/400000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, user: 'admin'} + }); + }); + + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/400000000000000000000003', + auth: {key: 'janedoe'}, + httpStatus: 200, + res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, user: 'admin'} + }); + }); + + it('returns a deleted sample for a maintain/admin user', done => { // TODO: make tests work + TestHelper.request(server, done, { + method: 'get', + url: '/sample/400000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 200, + res: {_id: '400000000000000000000005', number: 'Rng33', type: 'granulate', color: 'black', batch: '1653000308', condition: {condition_template: '200000000000000000000003'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {}, user: 'admin'} + }); + }); + + it('returns 403 for a write user when requesting a deleted sample', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/400000000000000000000005', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/000000000000000000000005', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/400000000h00000000000005', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/sample/400000000000000000000005', + httpStatus: 401 + }); + }); + }); + describe('PUT /sample/{id}', () => { it('returns the right sample', done => { TestHelper.request(server, done, { @@ -168,7 +251,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 200, req: {}, - res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} + res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} }); }); it('keeps unchanged properties', done => { @@ -177,21 +260,22 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', notes: {}} + req: {type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', notes: {}} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}); + should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}); SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { if (err) return done (err); - should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', 'status', '__v'); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v'); should(data).have.property('_id'); should(data).have.property('number', '1'); should(data).have.property('color', 'black'); should(data).have.property('type', 'granulate'); should(data).have.property('batch', ''); + should(data).have.property('condition', {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}); should(data.material_id.toString()).be.eql('100000000000000000000004'); should(data.user_id.toString()).be.eql('000000000000000000000002'); - should(data).have.property('status', 10); + should(data).have.property('status',globals.status.validated); should(data).have.property('note_id', null); done(); }); @@ -206,10 +290,27 @@ describe('/sample', () => { req: {type: 'granulate'} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}); + should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}); SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { if (err) return done (err); - should(data).have.property('status', 10); + should(data).have.property('status',globals.status.validated); + done(); + }); + }); + }); + it('keeps an unchanged condition', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}); + SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.property('status',globals.status.validated); done(); }); }); @@ -223,18 +324,21 @@ describe('/sample', () => { req: {notes: {comment: 'Stoff gesperrt', sample_references: []}} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '400000000000000000000002', number: '21', type: 'granulate', color: 'natural', batch: '1560237365', material_id: '100000000000000000000001', note_id: '500000000000000000000001', user_id: '000000000000000000000002'}); + should(res.body).be.eql({_id: '400000000000000000000002', number: '21', type: 'granulate', color: 'natural', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', note_id: '500000000000000000000001', user_id: '000000000000000000000002'}); SampleModel.findById('400000000000000000000002').lean().exec((err, data: any) => { if (err) return done (err); - should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', 'status', '__v'); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v'); should(data).have.property('_id'); should(data).have.property('number', '21'); should(data).have.property('color', 'natural'); should(data).have.property('type', 'granulate'); should(data).have.property('batch', '1560237365'); + should(data.condition).have.property('material', 'copper'); + should(data.condition).have.property('weeks', 3); + should(data.condition.condition_template.toString()).be.eql('200000000000000000000001'); should(data.material_id.toString()).be.eql('100000000000000000000001'); should(data.user_id.toString()).be.eql('000000000000000000000002'); - should(data).have.property('status', 10); + should(data).have.property('status',globals.status.validated); should(data.note_id.toString()).be.eql('500000000000000000000001'); done(); }); @@ -246,20 +350,21 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {type: 'part', color: 'signalviolet', batch: '114531', condition: {condition_template: '200000000000000000000003'}, material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} }).end(err => { if (err) return done (err); SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { if (err) return done (err); - should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', 'status', '__v'); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v'); should(data).have.property('_id'); should(data).have.property('number', '1'); should(data).have.property('color', 'signalviolet'); should(data).have.property('type', 'part'); should(data).have.property('batch', '114531'); + should(data).have.property('condition', {condition_template: '200000000000000000000003'}); should(data.material_id.toString()).be.eql('100000000000000000000002'); should(data.user_id.toString()).be.eql('000000000000000000000002'); - should(data).have.property('status', 0); + should(data).have.property('status',globals.status.new); should(data).have.property('note_id'); NoteModel.findById(data.note_id).lean().exec((err, data: any) => { if (err) return done (err); @@ -267,7 +372,7 @@ describe('/sample', () => { should(data).have.property('comment', 'Testcomment'); should(data).have.property('sample_references'); should(data.sample_references).have.lengthOf(1); - should(data.sample_references[0].id.toString()).be.eql('400000000000000000000003'); + should(data.sample_references[0].sample_id.toString()).be.eql('400000000000000000000003'); should(data.sample_references[0]).have.property('relation', 'part to this sample'); done(); }); @@ -350,7 +455,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Color not available for material'} }); }); @@ -360,7 +465,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '000000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '000000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Material not available'} }); }); @@ -370,7 +475,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: 25, type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {number: 25, type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"number" is not allowed'} }); }); @@ -380,7 +485,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '000000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '000000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Sample reference not available'} }); }); @@ -390,7 +495,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'} }); }); @@ -400,7 +505,87 @@ describe('/sample', () => { url: '/sample/10000000000h000000000001', auth: {basic: 'janedoe'}, httpStatus: 404, - req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }); + }); + it('rejects not specified condition parameters', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {material: 'copper', weeks: 3, xxx: 44, condition_template: '200000000000000000000001'}}, + res: {status: 'Invalid body format', details: '"xxx" is not allowed'} + }); + }); + it('rejects a condition parameter not in the value range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {material: 'xx', weeks: 3, condition_template: '200000000000000000000001'}}, + res: {status: 'Invalid body format', details: '"material" must be one of [copper, hot air]'} + }); + }); + it('rejects a condition parameter below minimum range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {material: 'copper', weeks: 0, condition_template: '200000000000000000000001'}}, + res: {status: 'Invalid body format', details: '"weeks" must be larger than or equal to 1'} + }); + }); + it('rejects a condition parameter above maximum range', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {material: 'copper', weeks: 10.5, condition_template: '200000000000000000000001'}}, + res: {status: 'Invalid body format', details: '"weeks" must be less than or equal to 10'} + }); + }); + it('rejects an invalid condition template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {material: 'copper', weeks: 3, condition_template: '200000000000h00000000001'}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects an unknown condition template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {material: 'copper', weeks: 3, condition_template: '000000000000000000000001'}}, + res: {status: 'Condition template not available'} + }); + }); + it('allows keeping an empty condition empty', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000006', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {condition: {}}, + res: {_id: '400000000000000000000006', number: 'Rng36', type: 'granulate', color: 'black', batch: '', condition: {}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} + }); + }); + it('rejects an changing back to an empty condition', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {}}, + res: {status: 'Condition template not available'} }); }); it('rejects an API key', done => { @@ -409,7 +594,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {key: 'janedoe'}, httpStatus: 401, - req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, }); }); it('rejects changes for samples from another user for a write user', done => { @@ -428,7 +613,7 @@ describe('/sample', () => { auth: {basic: 'admin'}, httpStatus: 200, req: {}, - res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} + res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {condition_template: '200000000000000000000001', material: 'copper', weeks: 3}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} }); }); it('rejects requests from a read user', done => { @@ -437,7 +622,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000001', auth: {basic: 'user'}, httpStatus: 403, - req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, }); }); it('returns 404 for an unknown sample', done => { @@ -446,7 +631,7 @@ describe('/sample', () => { url: '/sample/000000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 404, - req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} }); }) it('rejects unauthorized requests', done => { @@ -454,7 +639,7 @@ describe('/sample', () => { method: 'put', url: '/sample/400000000000000000000001', httpStatus: 401, - req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, }); }); }); @@ -471,15 +656,18 @@ describe('/sample', () => { should(res.body).be.eql({status: 'OK'}); SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { if (err) return done(err); - should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', 'status', '__v'); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v'); should(data).have.property('_id'); should(data).have.property('number', '1'); should(data).have.property('color', 'black'); should(data).have.property('type', 'granulate'); should(data).have.property('batch', ''); + should(data.condition).have.property('material', 'copper'); + should(data.condition).have.property('weeks', 3); + should(data.condition.condition_template.toString()).be.eql('200000000000000000000001'); should(data.material_id.toString()).be.eql('100000000000000000000004'); should(data.user_id.toString()).be.eql('000000000000000000000002'); - should(data).have.property('status', -1); + should(data).have.property('status',globals.status.deleted); should(data).have.property('note_id', null); done(); }); @@ -536,7 +724,7 @@ describe('/sample', () => { NoteModel.findById('500000000000000000000003').lean().exec((err, data: any) => { if (err) return done(err); should(data).have.property('sample_references').with.lengthOf(1); - should(data.sample_references[0].id.toString()).be.eql('400000000000000000000003'); + should(data.sample_references[0].sample_id.toString()).be.eql('400000000000000000000003'); should(data.sample_references[0]).have.property('relation', 'part to sample'); done(); }); @@ -555,7 +743,7 @@ describe('/sample', () => { should(res.body).be.eql({status: 'OK'}); SampleModel.findById('400000000000000000000001').lean().exec((err, data) => { if (err) return done(err); - should(data).have.property('status', -1); + should(data).have.property('status',globals.status.deleted); done(); }); }); @@ -617,15 +805,16 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} }).end((err, res) => { if (err) return done (err); - should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id'); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); should(res.body).have.property('_id').be.type('string'); - should(res.body).have.property('number', 'Rng34'); + should(res.body).have.property('number', 'Rng37'); should(res.body).have.property('color', 'black'); should(res.body).have.property('type', 'granulate'); should(res.body).have.property('batch', '1560237365'); + should(res.body).have.property('condition', {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}); should(res.body).have.property('material_id', '100000000000000000000001'); should(res.body).have.property('note_id').be.type('string'); should(res.body).have.property('user_id', '000000000000000000000002'); @@ -638,21 +827,22 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} }).end(err => { if (err) return done (err); - SampleModel.find({number: 'Rng34'}).lean().exec((err, data: any) => { + SampleModel.find({number: 'Rng37'}).lean().exec((err, data: any) => { if (err) return done (err); should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id', 'status', '__v'); + should(data[0]).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v'); should(data[0]).have.property('_id'); - should(data[0]).have.property('number', 'Rng34'); + should(data[0]).have.property('number', 'Rng37'); should(data[0]).have.property('color', 'black'); should(data[0]).have.property('type', 'granulate'); should(data[0]).have.property('batch', '1560237365'); + should(data[0]).have.property('condition', {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}); should(data[0].material_id.toString()).be.eql('100000000000000000000001'); should(data[0].user_id.toString()).be.eql('000000000000000000000002'); - should(data[0]).have.property('status', 0); + should(data[0]).have.property('status',globals.status.new); should(data[0]).have.property('note_id'); NoteModel.findById(data[0].note_id).lean().exec((err, data: any) => { if (err) return done (err); @@ -660,7 +850,7 @@ describe('/sample', () => { should(data).have.property('comment', 'Testcomment'); should(data).have.property('sample_references'); should(data.sample_references).have.lengthOf(1); - should(data.sample_references[0].id.toString()).be.eql('400000000000000000000003'); + should(data.sample_references[0].sample_id.toString()).be.eql('400000000000000000000003'); should(data.sample_references[0]).have.property('relation', 'part to this sample'); done(); }); @@ -710,10 +900,10 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'johnnydoe'}, httpStatus: 200, - req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} }).end((err, res) => { if (err) return done (err); - should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'material_id', 'note_id', 'user_id'); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); should(res.body).have.property('_id').be.type('string'); should(res.body).have.property('number', 'Fe1'); should(res.body).have.property('color', 'black'); @@ -725,13 +915,35 @@ describe('/sample', () => { done(); }); }); + it('accepts a sample without condition', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('number', 'Rng37'); + should(res.body).have.property('color', 'black'); + should(res.body).have.property('type', 'granulate'); + should(res.body).have.property('batch', '1560237365'); + should(res.body).have.property('condition', {}); + should(res.body).have.property('material_id', '100000000000000000000001'); + should(res.body).have.property('note_id').be.type('string'); + should(res.body).have.property('user_id', '000000000000000000000002'); + done(); + }); + }); it('rejects a color not defined for the material', done => { TestHelper.request(server, done, { method: 'post', url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {color: 'green', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'green', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Color not available for material'} }); }); @@ -741,7 +953,7 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '000000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '000000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Material not available'} }); }); @@ -751,7 +963,7 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {number: 'Rng34', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {number: 'Rng34', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"number" is not allowed'} }); }); @@ -761,17 +973,97 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '000000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '000000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Sample reference not available'} }); }); + it('rejects an invalid condition_template id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '20000h000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects a not existing condition_template id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '000000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects not specified condition parameters', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, xxx: 23, condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects missing condition parameters', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects condition parameters not in the value range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'xxx', weeks: 3, condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects a condition parameter below minimum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 0, condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects a condition parameter above maximum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 11, condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); + it('rejects a condition without condition template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Condition template not available'} + }); + }); it('rejects a missing color', done => { TestHelper.request(server, done, { method: 'post', url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"color" is required'} }); }); @@ -781,7 +1073,7 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {color: 'black', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'black', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"type" is required'} }); }); @@ -791,7 +1083,7 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {color: 'black', type: 'granulate', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'black', type: 'granulate', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"batch" is required'} }); }); @@ -801,7 +1093,7 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {color: 'black', type: 'granulate', batch: '1560237365', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'black', type: 'granulate', batch: '1560237365', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"material_id" is required'} }); }); @@ -811,7 +1103,7 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'} }); }); @@ -821,7 +1113,7 @@ describe('/sample', () => { url: '/sample/new', auth: {key: 'janedoe'}, httpStatus: 401, - req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} }); }); it('rejects requests from a read user', done => { @@ -830,7 +1122,7 @@ describe('/sample', () => { url: '/sample/new', auth: {basic: 'user'}, httpStatus: 403, - req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} }); }); it('rejects unauthorized requests', done => { @@ -838,7 +1130,7 @@ describe('/sample', () => { method: 'post', url: '/sample/new', httpStatus: 401, - req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} }); }); }); diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 43acd6e..ed1afb3 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -5,10 +5,15 @@ import SampleValidate from './validate/sample'; import NoteFieldValidate from './validate/note_field'; import res400 from './validate/res400'; import SampleModel from '../models/sample' +import MeasurementModel from '../models/measurement'; import MaterialModel from '../models/material'; import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; import IdValidate from './validate/id'; +import mongoose from "mongoose"; +import ConditionTemplateModel from '../models/condition_template'; +import ParametersValidate from './validate/parameters'; +import globals from '../globals'; const router = express.Router(); @@ -16,7 +21,7 @@ const router = express.Router(); router.get('/samples', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - SampleModel.find({status: 10}).lean().exec((err, data) => { + SampleModel.find({status: globals.status.validated}).lean().exec((err, data) => { if (err) return next(err); res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors }) @@ -25,17 +30,32 @@ router.get('/samples', (req, res, next) => { router.get('/samples/:group(new|deleted)', (req, res, next) => { if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; - let status; - switch (req.params.group) { - case 'new': status = 0; - break; - case 'deleted': status = -1; - break; - } - SampleModel.find({status: status}).lean().exec((err, data) => { + SampleModel.find({status: globals.status[req.params.group]}).lean().exec((err, data) => { if (err) return next(err); res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors - }) + }); +}); + +router.get('/sample/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + SampleModel.findById(req.params.id).populate('material_id').populate('user_id', 'name').populate('note_id').lean().exec((err, sampleData: any) => { + if (err) return next(err); + + if (sampleData) { + if (sampleData.status ===globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted samples only available for maintain/admin + sampleData.material = sampleData.material_id; // map data to right keys + sampleData.user = sampleData.user_id.name; + sampleData.notes = sampleData.note_id ? sampleData.note_id : {}; + MeasurementModel.find({sample_id: mongoose.Types.ObjectId(req.params.id)}).lean().exec((err, data) => { + sampleData.measurements = data; + res.json(SampleValidate.output(sampleData, 'details')); + }); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); }); router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { @@ -60,6 +80,10 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!await materialCheck(sample, res, next, sampleData.material_id)) return; } + if (sample.hasOwnProperty('condition') && !(_.isEmpty(sample.condition) && _.isEmpty(sampleData.condition))) { // do not execute check if condition is and was empty + if (!await conditionCheck(sample.condition, 'change', res, next)) return; + } + if (sample.hasOwnProperty('notes')) { let newNotes = true; if (sampleData.note_id !== null) { // old notes data exists @@ -89,10 +113,10 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { // check for changes if (!_.isEqual(_.pick(IdValidate.stringify(sampleData), _.keys(sample)), _.omit(sample, ['notes']))) { - sample.status = 0; + sample.status = globals.status.new; } - await SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).lean().exec((err, data) => { + await SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).lean().exec((err, data: any) => { if (err) return next(err); res.json(SampleValidate.output(data)); }); @@ -112,7 +136,7 @@ router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { // only maintain and admin are allowed to edit other user's data if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; - await SampleModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec(err => { // set sample status + await SampleModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).lean().exec(err => { // set sample status if (err) return next(err); if (sampleData.note_id !== null) { // handle notes NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { // find notes to update note_fields @@ -133,6 +157,10 @@ router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { router.post('/sample/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + if (!req.body.hasOwnProperty('condition')) { // add empty condition if not specified + req.body.condition = {}; + } + const {error, value: sample} = SampleValidate.input(req.body, 'new'); if (error) return res400(error, res); @@ -143,7 +171,11 @@ router.post('/sample/new', async (req, res, next) => { customFieldsChange(Object.keys(sample.notes.custom_fields), 1); } - sample.status = 0; // set status to new + if (!_.isEmpty(sample.condition)) { // do not execute check if condition is empty + if (!await conditionCheck(sample.condition, 'change', res, next)) return; + } + + sample.status = globals.status.new; // set status to new sample.number = await numberGenerate(sample, req, res, next); if (!sample.number) return; @@ -152,6 +184,7 @@ router.post('/sample/new', async (req, res, next) => { delete sample.notes; sample.note_id = data._id; sample.user_id = req.authDetails.id; + new SampleModel(sample).save((err, data) => { if (err) return next(err); res.json(SampleValidate.output(data.toObject())); @@ -172,14 +205,15 @@ router.get('/sample/notes/fields', (req, res, next) => { module.exports = router; -async function numberGenerate (sample, req, res, next) { // generate number, returns false on error +async function numberGenerate (sample, req, res, next) { // generate number in format Location32, returns false on error const sampleData = await SampleModel - .find({number: new RegExp('^' + req.authDetails.location + '[0-9]+$', 'm')}) + .findOne({number: new RegExp('^' + req.authDetails.location + '[0-9]+$', 'm')}) + .sort({number: -1}) .lean() .exec() .catch(err => next(err)); if (sampleData instanceof Error) return false; - return req.authDetails.location + (sampleData.length > 0 ? Number(sampleData[0].number.replace(/[^0-9]+/g, '')) + 1 : 1); + return req.authDetails.location + (sampleData ? Number(sampleData.number.replace(/[^0-9]+/g, '')) + 1 : 1); } async function materialCheck (sample, res, next, id = sample.material_id) { // validate material_id and color, returns false if invalid @@ -196,13 +230,31 @@ async function materialCheck (sample, res, next, id = sample.material_id) { // return true; } +async function conditionCheck (condition, param, res, next) { // validate treatment template, returns false if invalid, otherwise template data + if (!condition.condition_template || !IdValidate.valid(condition.condition_template)) { // template id not found + res.status(400).json({status: 'Condition template not available'}); + return false; + } + const conditionData = await ConditionTemplateModel.findById(condition.condition_template).lean().exec().catch(err => next(err)) as any; + if (conditionData instanceof Error) return false; + if (!conditionData) { // template not found + res.status(400).json({status: 'Condition template not available'}); + return false; + } + + // validate parameters + const {error, value: ignore} = ParametersValidate.input(_.omit(condition, 'condition_template'), conditionData.parameters, param); + if (error) {res400(error, res); return false;} + return conditionData; +} + function sampleRefCheck (sample, res, next) { // validate sample_references, resolves false for invalid reference return new Promise(resolve => { if (sample.notes.sample_references.length > 0) { // there are sample_references let referencesCount = sample.notes.sample_references.length; // count to keep track of running async operations sample.notes.sample_references.forEach(reference => { - SampleModel.findById(reference.id).lean().exec((err, data) => { + SampleModel.findById(reference.sample_id).lean().exec((err, data) => { if (err) {next(err); resolve(false)} if (!data) { res.status(400).json({status: 'Sample reference not available'}); @@ -230,7 +282,7 @@ function customFieldsChange (fields, amount) { // update custom_fields and resp if (err) return console.error(err); }) } - else if (data.qty <= 0) { + else if (data.qty <= 0) { // delete document if field is not used anymore NoteFieldModel.findOneAndDelete({name: field}).lean().exec(err => { if (err) return console.error(err); }); diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index 878b778..2ac09ae 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -1,10 +1,12 @@ import should from 'should/as-function'; import _ from 'lodash'; -import TemplateTreatmentModel from '../models/treatment_template'; +import TemplateConditionModel from '../models/condition_template'; import TemplateMeasurementModel from '../models/measurement_template'; import TestHelper from "../test/helper"; // TODO: do not allow usage of old templates for new samples +// TODO: remove number_prefix +// TODO: template parameters are not allowed to be condition_template describe('/template', () => { let server; @@ -12,25 +14,24 @@ describe('/template', () => { beforeEach(done => server = TestHelper.beforeEach(server, done)); afterEach(done => TestHelper.afterEach(server, done)); - describe('/template/treatment', () => { - describe('GET /template/treatments', () => { - it('returns all treatment templates', done => { + describe('/template/condition', () => { + describe('GET /template/conditions', () => { + it('returns all condition templates', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/treatments', + url: '/template/conditions', auth: {basic: 'janedoe'}, httpStatus: 200 }).end((err, res) => { if (err) return done(err); const json = require('../test/db.json'); - should(res.body).have.lengthOf(json.collections.treatment_templates.length); - should(res.body).matchEach(treatment => { - should(treatment).have.only.keys('_id', 'name', 'version', 'parameters', 'number_prefix'); - should(treatment).have.property('_id').be.type('string'); - should(treatment).have.property('name').be.type('string'); - should(treatment).have.property('version').be.type('number'); - should(treatment).have.property('number_prefix').be.type('string'); - should(treatment.parameters).matchEach(number => { + should(res.body).have.lengthOf(json.collections.condition_templates.length); + should(res.body).matchEach(condition => { + should(condition).have.only.keys('_id', 'name', 'version', 'parameters'); + should(condition).have.property('_id').be.type('string'); + should(condition).have.property('name').be.type('string'); + should(condition).have.property('version').be.type('number'); + should(condition.parameters).matchEach(number => { should(number).have.only.keys('name', 'range'); should(number).have.property('name').be.type('string'); should(number).have.property('range').be.type('object'); @@ -42,7 +43,7 @@ describe('/template', () => { it('rejects an API key', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/treatments', + url: '/template/conditions', auth: {key: 'janedoe'}, httpStatus: 401 }); @@ -50,26 +51,26 @@ describe('/template', () => { it('rejects unauthorized requests', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/treatments', + url: '/template/conditions', httpStatus: 401 }); }); }); - describe('GET /template/treatment/{id}', () => { - it('returns the right treatment template', done => { + describe('GET /template/condition/{id}', () => { + it('returns the right condition template', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, number_prefix: 'A', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} }); }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {key: 'janedoe'}, httpStatus: 401 }); @@ -77,7 +78,7 @@ describe('/template', () => { it('rejects an unknown id', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/treatment/000000000000000000000001', + url: '/template/condition/000000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 404 }); @@ -85,58 +86,57 @@ describe('/template', () => { it('rejects unauthorized requests', done => { TestHelper.request(server, done, { method: 'get', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', httpStatus: 401 }); }); }); - describe('PUT /template/treatment/{name}', () => { - it('returns the right treatment template', done => { + describe('PUT /template/condition/{name}', () => { + it('returns the right condition template', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {}, - res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, number_prefix: 'A', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} }); }); it('keeps unchanged properties', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]}, - res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, number_prefix: 'A', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} }); }); it('keeps only one unchanged property', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {name: 'heat treatment'}, - res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, number_prefix: 'A', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} + res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]} }); }); it('changes the given properties', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} }).end((err, res) => { if (err) return done(err); - TemplateTreatmentModel.findById(res.body._id).lean().exec((err, data:any) => { + TemplateConditionModel.findById(res.body._id).lean().exec((err, data:any) => { if (err) return done(err); - should(data).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters', '__v'); + should(data).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); should(data).have.property('name', 'heat aging'); should(data).have.property('version', 2); - should(data).have.property('number_prefix', 'A'); should(data).have.property('parameters').have.lengthOf(1); should(data.parameters[0]).have.property('name', 'time'); should(data.parameters[0]).have.property('range'); @@ -148,18 +148,17 @@ describe('/template', () => { it('allows changing only one property', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {name: 'heat aging'} }).end((err, res) => { if (err) return done(err); - TemplateTreatmentModel.findById(res.body._id).lean().exec((err, data:any) => { + TemplateConditionModel.findById(res.body._id).lean().exec((err, data:any) => { if (err) return done(err); - should(data).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters', '__v'); + should(data).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); should(data).have.property('name', 'heat aging'); should(data).have.property('version', 2); - should(data).have.property('number_prefix', 'A'); should(data).have.property('parameters').have.lengthOf(2); should(data.parameters[0]).have.property('name', 'material'); should(data.parameters[1]).have.property('name', 'weeks'); @@ -170,59 +169,59 @@ describe('/template', () => { it('supports values ranges', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {parameters: [{name: 'time', range: {values: [1, 2, 5]}}]} }).end((err, res) => { if (err) return done(err); - should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {values: [1, 2, 5]}}]}); + should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, parameters: [{name: 'time', range: {values: [1, 2, 5]}}]}); done(); }); }); it('supports min max ranges', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {parameters: [{name: 'time', range: {min: 1, max: 11}}]} }).end((err, res) => { if (err) return done(err); - should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {min: 1, max: 11}}]}); + should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, parameters: [{name: 'time', range: {min: 1, max: 11}}]}); done(); }); }); it('supports array type ranges', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {parameters: [{name: 'time', range: {type: 'array'}}]} }).end((err, res) => { if (err) return done(err); - should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {type: 'array'}}]}); + should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, parameters: [{name: 'time', range: {type: 'array'}}]}); done(); }); }); it('supports empty ranges', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, req: {parameters: [{name: 'time', range: {}}]} }).end((err, res) => { if (err) return done(err); - should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {}}]}); + should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, parameters: [{name: 'time', range: {}}]}); done(); }); }); it('rejects not specified parameters', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {basic: 'admin'}, httpStatus: 400, req: {name: 'heat treatment', parameters: [{name: 'material', range: {xx: 5}}]}, @@ -232,7 +231,7 @@ describe('/template', () => { it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/2000000000h0000000000001', + url: '/template/condition/2000000000h0000000000001', auth: {basic: 'admin'}, httpStatus: 404, req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} @@ -241,26 +240,16 @@ describe('/template', () => { it('rejects an unknown id', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/000000000000000000000001', + url: '/template/condition/000000000000000000000001', auth: {basic: 'admin'}, httpStatus: 404, req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} }); }); - it('rejects already existing number prefixes', done => { - TestHelper.request(server, done, { - method: 'put', - url: '/template/treatment/200000000000000000000001', - auth: {basic: 'admin'}, - httpStatus: 400, - req: {number_prefix: 'B', parameters: [{name: 'time', range: {min: 1}}]}, - res: {status: 'Number prefix already taken'} - }); - }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {key: 'admin'}, httpStatus: 401, req: {} @@ -269,7 +258,7 @@ describe('/template', () => { it('rejects requests from a write user', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 403, req: {} @@ -278,27 +267,26 @@ describe('/template', () => { it('rejects unauthorized requests', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/treatment/200000000000000000000001', + url: '/template/condition/200000000000000000000001', httpStatus: 401, req: {} }); }); }); - describe('POST /template/treatment/new', () => { - it('returns the right treatment template', done => { + describe('POST /template/condition/new', () => { + it('returns the right condition template', done => { TestHelper.request(server, done, { method: 'post', - url: '/template/treatment/new', + url: '/template/condition/new', auth: {basic: 'admin'}, httpStatus: 200, - req: {name: 'heat treatment3', number_prefix: 'C', parameters: [{name: 'material', range: {values: ['copper']}}]} + req: {name: 'heat treatment3', parameters: [{name: 'material', range: {values: ['copper']}}]} }).end((err, res) => { if (err) return done(err); - should(res.body).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters'); + should(res.body).have.only.keys('_id', 'name', 'version', 'parameters'); should(res.body).have.property('name', 'heat treatment3'); should(res.body).have.property('version', 1); - should(res.body).have.property('number_prefix', 'C'); should(res.body).have.property('parameters').have.lengthOf(1); should(res.body.parameters[0]).have.property('name', 'material'); should(res.body.parameters[0]).have.property('range'); @@ -310,18 +298,17 @@ describe('/template', () => { it('stores the template', done => { TestHelper.request(server, done, { method: 'post', - url: '/template/treatment/new', + url: '/template/condition/new', auth: {basic: 'admin'}, httpStatus: 200, - req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {min: 1}}]} + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} }).end((err, res) => { if (err) return done(err); - TemplateTreatmentModel.findById(res.body._id).lean().exec((err, data:any) => { + TemplateConditionModel.findById(res.body._id).lean().exec((err, data:any) => { if (err) return done(err); - should(data).have.only.keys('_id', 'name', 'version', 'number_prefix', 'parameters', '__v'); + should(data).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); should(data).have.property('name', 'heat aging'); should(data).have.property('version', 1); - should(data).have.property('number_prefix', 'C'); should(data).have.property('parameters').have.lengthOf(1); should(data.parameters[0]).have.property('name', 'time'); should(data.parameters[0]).have.property('range'); @@ -333,117 +320,97 @@ describe('/template', () => { it('rejects a missing name', done => { TestHelper.request(server, done, { method: 'post', - url: '/template/treatment/new', + url: '/template/condition/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {number_prefix: 'C', parameters: [{name: 'time', range: {min: 1}}]}, + req: {parameters: [{name: 'time', range: {min: 1}}]}, res: {status: 'Invalid body format', details: '"name" is required'} }); }); - it('rejects a missing number prefix', done => { + it('rejects a number prefix', done => { TestHelper.request(server, done, { method: 'post', - url: '/template/treatment/new', + url: '/template/condition/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}, - res: {status: 'Invalid body format', details: '"number_prefix" is required'} + req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {min: 1}}]}, + res: {status: 'Invalid body format', details: '"number_prefix" is not allowed'} }); }); it('rejects missing parameters', done => { TestHelper.request(server, done, { method: 'post', - url: '/template/treatment/new', + url: '/template/condition/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {name: 'heat aging', number_prefix: 'C'}, + req: {name: 'heat aging'}, res: {status: 'Invalid body format', details: '"parameters" is required'} }); }); it('rejects a missing parameter name', done => { TestHelper.request(server, done, { method: 'post', - url: '/template/treatment/new', + url: '/template/condition/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {name: 'heat aging', number_prefix: 'C', parameters: [{range: {min: 1}}]}, + req: {name: 'heat aging', parameters: [{range: {min: 1}}]}, res: {status: 'Invalid body format', details: '"parameters[0].name" is required'} }); }); - it('rejects a number prefix containing numbers', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/template/treatment/new', - auth: {basic: 'admin'}, - httpStatus: 400, - req: {name: 'heat aging', number_prefix: 'AB5', parameters: [{name: 'time', range: {min: 1}}]}, - res: {status: 'Invalid body format', details: '"number_prefix" with value "AB5" fails to match the required pattern: /^[a-zA-Z]+$/'} - }); - }); it('rejects a missing parameter range', done => { TestHelper.request(server, done, { method: 'post', - url: '/template/treatment/new', + url: '/template/condition/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time'}]}, + req: {name: 'heat aging', parameters: [{name: 'time'}]}, res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} }); }); it('rejects an invalid parameter range property', done => { TestHelper.request(server, done, { method: 'post', - url: '/template/treatment/new', + url: '/template/condition/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {xx: 1}}]}, + req: {name: 'heat aging', parameters: [{name: 'time', range: {xx: 1}}]}, res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} }); }); it('rejects wrong properties', done => { TestHelper.request(server, done, { method: 'post', - url: '/template/treatment/new', + url: '/template/condition/new', auth: {basic: 'admin'}, httpStatus: 400, - req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {}}], xx: 33}, + req: {name: 'heat aging', parameters: [{name: 'time', range: {}}], xx: 33}, res: {status: 'Invalid body format', details: '"xx" is not allowed'} }); }); - it('rejects already existing number prefixes', done => { - TestHelper.request(server, done, { - method: 'post', - url: '/template/treatment/new', - auth: {basic: 'admin'}, - httpStatus: 400, - req: {name: 'heat aging', number_prefix: 'B', parameters: [{name: 'time', range: {min: 1}}]}, - res: {status: 'Number prefix already taken'} - }); - }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'post', - url: '/template/treatment/new', + url: '/template/condition/new', auth: {key: 'admin'}, httpStatus: 401, - req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {min: 1}}]} + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} }); }); it('rejects requests from a write user', done => { TestHelper.request(server, done, { method: 'post', - url: '/template/treatment/new', + url: '/template/condition/new', auth: {basic: 'janedoe'}, httpStatus: 403, - req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {min: 1}}]} + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} }); }); it('rejects unauthorized requests', done => { TestHelper.request(server, done, { method: 'post', - url: '/template/treatment/new', + url: '/template/condition/new', httpStatus: 401, - req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {min: 1}}]} + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]} }); }); }); diff --git a/src/routes/template.ts b/src/routes/template.ts index a8f7413..f4054c1 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -2,8 +2,8 @@ import express from 'express'; import _ from 'lodash'; import TemplateValidate from './validate/template'; -import TemplateTreatmentModel from '../models/treatment_template'; -import TemplateMeasurementModel from '../models/measurement_template'; +import ConditionTemplateModel from '../models/condition_template'; +import MeasurementTemplateModel from '../models/measurement_template'; import res400 from './validate/res400'; import IdValidate from './validate/id'; @@ -11,23 +11,23 @@ import IdValidate from './validate/id'; const router = express.Router(); -router.get('/template/:collection(measurements|treatments)', (req, res, next) => { +router.get('/template/:collection(measurements|conditions)', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; req.params.collection = req.params.collection.replace(/s$/g, ''); // remove trailing s model(req).find({}).lean().exec((err, data) => { if (err) next (err); - res.json(_.compact(data.map(e => TemplateValidate.output(e, req.params.collection)))); // validate all and filter null values from validation errors + res.json(_.compact(data.map(e => TemplateValidate.output(e)))); // validate all and filter null values from validation errors }); }); -router.get('/template/:collection(measurement|treatment)/' + IdValidate.parameter(), (req, res, next) => { +router.get('/template/:collection(measurement|condition)/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; model(req).findById(req.params.id).lean().exec((err, data) => { if (err) next (err); if (data) { - res.json(TemplateValidate.output(data, req.params.collection)); + res.json(TemplateValidate.output(data)); } else { res.status(404).json({status: 'Not found'}); @@ -35,10 +35,10 @@ router.get('/template/:collection(measurement|treatment)/' + IdValidate.paramete }); }); -router.put('/template/:collection(measurement|treatment)/' + IdValidate.parameter(), async (req, res, next) => { +router.put('/template/:collection(measurement|condition)/' + IdValidate.parameter(), async (req, res, next) => { if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; - const {error, value: template} = TemplateValidate.input(req.body, 'change', req.params.collection); + const {error, value: template} = TemplateValidate.input(req.body, 'change'); if (error) return res400(error, res); const templateData = await model(req).findById(req.params.id).lean().exec().catch(err => {next(err);}) as any; @@ -47,52 +47,34 @@ router.put('/template/:collection(measurement|treatment)/' + IdValidate.paramete res.status(404).json({status: 'Not found'}); } - if (_.has(template, 'number_prefix') && template.number_prefix !== templateData.number_prefix) { // got new number_prefix - if (!await numberPrefixCheck(template, req, res, next)) return; - } - if (!_.isEqual(_.pick(templateData, _.keys(template)), template)) { // data was changed template.version = templateData.version + 1; // increase version await new (model(req))(_.assign({}, _.omit(templateData, ['_id', '__v']), template)).save((err, data) => { // save new template, fill with old properties if (err) next (err); - res.json(TemplateValidate.output(data.toObject(), req.params.collection)); + res.json(TemplateValidate.output(data.toObject())); }); } else { - res.json(TemplateValidate.output(templateData, req.params.collection)); + res.json(TemplateValidate.output(templateData)); } }); -router.post('/template/:collection(measurement|treatment)/new', async (req, res, next) => { +router.post('/template/:collection(measurement|condition)/new', async (req, res, next) => { if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; - const {error, value: template} = TemplateValidate.input(req.body, 'new', req.params.collection); + const {error, value: template} = TemplateValidate.input(req.body, 'new'); if (error) return res400(error, res); - if (_.has(template, 'number_prefix')) { // got number_prefix - if (!await numberPrefixCheck(template, req, res, next)) return; - } - template.version = 1; // set template version await new (model(req))(template).save((err, data) => { if (err) next (err); - res.json(TemplateValidate.output(data.toObject(), req.params.collection)); + res.json(TemplateValidate.output(data.toObject())); }); }); module.exports = router; - -async function numberPrefixCheck (template, req, res, next) { // check if number_prefix is available - const data = await model(req).findOne({number_prefix: template.number_prefix}).lean().exec().catch(err => {next(err); return false;}) as any; - if (data) { - res.status(400).json({status: 'Number prefix already taken'}); - return false - } - return true; -} - function model (req) { // return right template model - return req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel; + return req.params.collection === 'condition' ? ConditionTemplateModel : MeasurementTemplateModel; } \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts index 4fb2c0f..6ebed4b 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -40,7 +40,6 @@ router.put('/user:username([/](?!key|new).?*|/?)', async (req, res, next) => { const username = getUsername(req, res); if (!username) return; - console.log(username); const {error, value: user} = UserValidate.input(req.body, 'change' + (req.authDetails.level === 'admin'? 'admin' : '')); if (error) return res400(error, res); @@ -154,8 +153,6 @@ function getUsername (req, res) { // returns username or false if action is not async function usernameCheck (name, res, next) { // check if username is already taken const userData = await UserModel.findOne({name: name}).lean().exec().catch(err => next(err)) as any; if (userData instanceof Error) return false; - console.log(userData); - console.log(UserValidate.isSpecialName(name)); if (userData || UserValidate.isSpecialName(name)) { res.status(400).json({status: 'Username already taken'}); return false; diff --git a/src/routes/validate/condition.ts b/src/routes/validate/condition.ts deleted file mode 100644 index d752ff3..0000000 --- a/src/routes/validate/condition.ts +++ /dev/null @@ -1,50 +0,0 @@ -import Joi from '@hapi/joi'; - -import IdValidate from './id'; - -export default class ConditionValidate { - private static condition = { - number: Joi.string() - .max(128), - - parameters: Joi.object() - .pattern(/.*/, Joi.alternatives() - .try( - Joi.string().max(128), - Joi.number(), - Joi.boolean(), - Joi.array() - ) - ) - } - - static input (data, param) { // validate input, set param to 'new' to make all attributes required - if (param === 'new') { - return Joi.object({ - sample_id: IdValidate.get().required(), - parameters: this.condition.parameters.required(), - treatment_template: IdValidate.get().required() - }).validate(data); - } - else if (param === 'change') { - return Joi.object({ - parameters: this.condition.parameters - }).validate(data); - } - else { - return{error: 'No parameter specified!', value: {}}; - } - } - - static output (data) { // validate output and strip unwanted properties, returns null if not valid - data = IdValidate.stringify(data); - const {value, error} = Joi.object({ - _id: IdValidate.get(), - sample_id: IdValidate.get(), - number: this.condition.number, - parameters: this.condition.parameters, - treatment_template: IdValidate.get() - }).validate(data, {stripUnknown: true}); - return error !== undefined? null : value; - } -} \ No newline at end of file diff --git a/src/routes/validate/material.ts b/src/routes/validate/material.ts index c92f440..805ccd2 100644 --- a/src/routes/validate/material.ts +++ b/src/routes/validate/material.ts @@ -1,39 +1,39 @@ -import joi from '@hapi/joi'; +import Joi from '@hapi/joi'; import IdValidate from './id'; export default class MaterialValidate { // validate input for material private static material = { - name: joi.string() + name: Joi.string() .max(128), - supplier: joi.string() + supplier: Joi.string() .max(128), - group: joi.string() + group: Joi.string() .max(128), - mineral: joi.number() + mineral: Joi.number() .integer() .min(0) .max(100), - glass_fiber: joi.number() + glass_fiber: Joi.number() .integer() .min(0) .max(100), - carbon_fiber: joi.number() + carbon_fiber: Joi.number() .integer() .min(0) .max(100), - numbers: joi.array() - .items(joi.object({ - color: joi.string() + numbers: Joi.array() + .items(Joi.object({ + color: Joi.string() .max(128) .required(), - number: joi.string() + number: Joi.string() .max(128) .allow('') .required() @@ -42,7 +42,7 @@ export default class MaterialValidate { // validate input for material static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { - return joi.object({ + return Joi.object({ name: this.material.name.required(), supplier: this.material.supplier.required(), group: this.material.group.required(), @@ -53,7 +53,7 @@ export default class MaterialValidate { // validate input for material }).validate(data); } else if (param === 'change') { - return joi.object({ + return Joi.object({ name: this.material.name, supplier: this.material.supplier, group: this.material.group, @@ -70,7 +70,7 @@ export default class MaterialValidate { // validate input for material static output (data) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); - const {value, error} = joi.object({ + const {value, error} = Joi.object({ _id: IdValidate.get(), name: this.material.name, supplier: this.material.supplier, @@ -82,4 +82,17 @@ export default class MaterialValidate { // validate input for material }).validate(data, {stripUnknown: true}); return error !== undefined? null : value; } + + static outputV() { // return output validator + return Joi.object({ + _id: IdValidate.get(), + name: this.material.name, + supplier: this.material.supplier, + group: this.material.group, + mineral: this.material.mineral, + glass_fiber: this.material.glass_fiber, + carbon_fiber: this.material.carbon_fiber, + numbers: this.material.numbers + }); + } } \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 1b23cb1..93b86b1 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -1,6 +1,8 @@ import Joi from '@hapi/joi'; import IdValidate from './id'; +import UserValidate from './user'; +import MaterialValidate from './material'; export default class SampleValidate { private static sample = { @@ -17,13 +19,16 @@ export default class SampleValidate { .max(128) .allow(''), + condition: Joi.object(), + notes: Joi.object({ comment: Joi.string() - .max(512), + .max(512) + .allow(''), sample_references: Joi.array() .items(Joi.object({ - id: IdValidate.get(), + sample_id: IdValidate.get(), relation: Joi.string() .max(128) @@ -47,6 +52,7 @@ export default class SampleValidate { color: this.sample.color.required(), type: this.sample.type.required(), batch: this.sample.batch.required(), + condition: this.sample.condition.required(), material_id: IdValidate.get().required(), notes: this.sample.notes.required() }).validate(data); @@ -56,6 +62,7 @@ export default class SampleValidate { color: this.sample.color, type: this.sample.type, batch: this.sample.batch, + condition: this.sample.condition, material_id: IdValidate.get(), notes: this.sample.notes, }).validate(data); @@ -65,18 +72,39 @@ export default class SampleValidate { } } - static output (data) { // validate output and strip unwanted properties, returns null if not valid + static output (data, param = 'refs') { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); - const {value, error} = Joi.object({ - _id: IdValidate.get(), - number: this.sample.number, - color: this.sample.color, - type: this.sample.type, - batch: this.sample.batch, - material_id: IdValidate.get(), - note_id: IdValidate.get().allow(null), - user_id: IdValidate.get() - }).validate(data, {stripUnknown: true}); + let joiObject; + if (param === 'refs') { + joiObject = { + _id: IdValidate.get(), + number: this.sample.number, + color: this.sample.color, + type: this.sample.type, + batch: this.sample.batch, + condition: this.sample.condition, + material_id: IdValidate.get(), + note_id: IdValidate.get().allow(null), + user_id: IdValidate.get() + }; + } + else if(param === 'details') { + joiObject = { + _id: IdValidate.get(), + number: this.sample.number, + color: this.sample.color, + type: this.sample.type, + batch: this.sample.batch, + condition: this.sample.condition, + material: MaterialValidate.outputV(), + notes: this.sample.notes, + user: UserValidate.username() + } + } + else { + return null; + } + const {value, error} = Joi.object(joiObject).validate(data, {stripUnknown: true}); return error !== undefined? null : value; } } \ No newline at end of file diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts index 571f48c..6b96a42 100644 --- a/src/routes/validate/template.ts +++ b/src/routes/validate/template.ts @@ -9,11 +9,6 @@ export default class TemplateValidate { version: Joi.number() .min(1), - number_prefix: Joi.string() - .pattern(/^[a-zA-Z]+$/) - .min(1) - .max(16), - parameters: Joi.array() .min(1) .items( @@ -43,63 +38,32 @@ export default class TemplateValidate { ) }; - static input (data, param, template) { // validate input, set param to 'new' to make all attributes required + static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { - if (template === 'treatment') { - return Joi.object({ - name: this.template.name.required(), - number_prefix: this.template.number_prefix.required(), - parameters: this.template.parameters.required() - }).validate(data); - } - else { - return Joi.object({ - name: this.template.name.required(), - parameters: this.template.parameters.required() - }).validate(data); - } + return Joi.object({ + name: this.template.name.required(), + parameters: this.template.parameters.required() + }).validate(data); } else if (param === 'change') { - if (template === 'treatment') { - return Joi.object({ - name: this.template.name, - number_prefix: this.template.number_prefix, - parameters: this.template.parameters - }).validate(data); - } - else { - return Joi.object({ - name: this.template.name, - parameters: this.template.parameters - }).validate(data); - } + return Joi.object({ + name: this.template.name, + parameters: this.template.parameters + }).validate(data); } else { return{error: 'No parameter specified!', value: {}}; } } - static output (data, template) { // validate output and strip unwanted properties, returns null if not valid + static output (data) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); - let joiObject; - if (template === 'treatment') { // differentiate between measurement and treatment (has number_prefix) template - joiObject = { - _id: IdValidate.get(), - name: this.template.name, - version: this.template.version, - number_prefix: this.template.number_prefix, - parameters: this.template.parameters - }; - } - else { - joiObject = { - _id: IdValidate.get(), - name: this.template.name, - version: this.template.version, - parameters: this.template.parameters - }; - } - const {value, error} = Joi.object(joiObject).validate(data, {stripUnknown: true}); + const {value, error} = Joi.object({ + _id: IdValidate.get(), + name: this.template.name, + version: this.template.version, + parameters: this.template.parameters + }).validate(data, {stripUnknown: true}); return error !== undefined? null : value; } } \ No newline at end of file diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index bd4dfbd..9c0c7d1 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -84,4 +84,8 @@ export default class UserValidate { // validate input for user static isSpecialName (name) { // true if name belongs to special names return this.specialUsernames.indexOf(name) > -1; } + + static username() { + return this.user.name; + } } diff --git a/src/test/db.json b/src/test/db.json index b78f8e7..7760208 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -7,6 +7,11 @@ "type": "granulate", "color": "black", "batch": "", + "condition": { + "material": "copper", + "weeks": 3, + "condition_template": {"$oid":"200000000000000000000001"} + }, "material_id": {"$oid":"100000000000000000000004"}, "note_id": null, "user_id": {"$oid":"000000000000000000000002"}, @@ -19,6 +24,11 @@ "type": "granulate", "color": "natural", "batch": "1560237365", + "condition": { + "material": "copper", + "weeks": 3, + "condition_template": {"$oid":"200000000000000000000001"} + }, "material_id": {"$oid":"100000000000000000000001"}, "note_id": {"$oid":"500000000000000000000001"}, "user_id": {"$oid":"000000000000000000000002"}, @@ -31,6 +41,11 @@ "type": "part", "color": "black", "batch": "1704-005", + "condition": { + "material": "copper", + "weeks": 3, + "condition_template": {"$oid":"200000000000000000000001"} + }, "material_id": {"$oid":"100000000000000000000005"}, "note_id": {"$oid":"500000000000000000000002"}, "user_id": {"$oid":"000000000000000000000003"}, @@ -43,6 +58,11 @@ "type": "granulate", "color": "black", "batch": "1653000308", + "condition": { + "material": "hot air", + "weeks": 5, + "condition_template": {"$oid":"200000000000000000000001"} + }, "material_id": {"$oid":"100000000000000000000005"}, "note_id": {"$oid":"500000000000000000000003"}, "user_id": {"$oid":"000000000000000000000003"}, @@ -55,11 +75,27 @@ "type": "granulate", "color": "black", "batch": "1653000308", + "condition": { + "condition_template": {"$oid":"200000000000000000000003"} + }, "material_id": {"$oid":"100000000000000000000005"}, - "note_id": {"$oid":"500000000000000000000003"}, + "note_id": null, "user_id": {"$oid":"000000000000000000000003"}, "status": -1, "__v": 0 + }, + { + "_id": {"$oid":"400000000000000000000006"}, + "number": "Rng36", + "type": "granulate", + "color": "black", + "batch": "", + "condition": {}, + "material_id": {"$oid":"100000000000000000000004"}, + "note_id": null, + "user_id": {"$oid":"000000000000000000000002"}, + "status": 0, + "__v": 0 } ], "notes": [ @@ -73,7 +109,7 @@ "_id": {"$oid":"500000000000000000000002"}, "comment": "", "sample_references": [{ - "id": {"$oid":"400000000000000000000004"}, + "sample_id": {"$oid":"400000000000000000000004"}, "relation": "granulate to sample" }], "custom_fields": { @@ -85,7 +121,7 @@ "_id": {"$oid":"500000000000000000000003"}, "comment": "", "sample_references": [{ - "id": {"$oid":"400000000000000000000003"}, + "sample_id": {"$oid":"400000000000000000000003"}, "relation": "part to sample" }], "custom_fields": { @@ -234,60 +270,10 @@ "__v": 0 } ], - "conditions": [ - { - "_id": {"$oid":"700000000000000000000001"}, - "sample_id": {"$oid":"400000000000000000000001"}, - "number": "A1", - "parameters": { - "material": "copper", - "weeks": 3 - }, - "treatment_template": {"$oid":"200000000000000000000001"}, - "status": 10, - "__v": 0 - }, - { - "_id": {"$oid":"700000000000000000000002"}, - "sample_id": {"$oid":"400000000000000000000002"}, - "number": "A1", - "parameters": { - "material": "copper", - "weeks": 3 - }, - "treatment_template": {"$oid":"200000000000000000000001"}, - "status": 10, - "__v": 0 - }, - { - "_id": {"$oid":"700000000000000000000003"}, - "sample_id": {"$oid":"400000000000000000000004"}, - "number": "A1", - "parameters": { - "material": "copper", - "weeks": 3 - }, - "treatment_template": {"$oid":"200000000000000000000001"}, - "status": 10, - "__v": 0 - }, - { - "_id": {"$oid":"700000000000000000000004"}, - "sample_id": {"$oid":"400000000000000000000001"}, - "number": "A2", - "parameters": { - "material": "hot air", - "weeks": 5 - }, - "treatment_template": {"$oid":"200000000000000000000001"}, - "status": 10, - "__v": 0 - } - ], "measurements": [ { "_id": {"$oid":"800000000000000000000001"}, - "condition_id": {"$oid":"700000000000000000000001"}, + "sample_id": {"$oid":"400000000000000000000001"}, "values": { "dpt": [ [3997.12558,98.00555], @@ -301,7 +287,7 @@ }, { "_id": {"$oid":"800000000000000000000002"}, - "condition_id": {"$oid":"700000000000000000000002"}, + "sample_id": {"$oid":"400000000000000000000002"}, "values": { "weight %": 0.5, "standard deviation": 0.2 @@ -312,7 +298,7 @@ }, { "_id": {"$oid":"800000000000000000000003"}, - "condition_id": {"$oid":"700000000000000000000003"}, + "sample_id": {"$oid":"400000000000000000000003"}, "values": { "val1": 1 }, @@ -321,12 +307,11 @@ "__v": 0 } ], - "treatment_templates": [ + "condition_templates": [ { "_id": {"$oid":"200000000000000000000001"}, "name": "heat treatment", "version": 1, - "number_prefix": "A", "parameters": [ { "name": "material", @@ -351,7 +336,6 @@ "_id": {"$oid":"200000000000000000000002"}, "name": "heat treatment 2", "version": 2, - "number_prefix": "B", "parameters": [ { "name": "material", @@ -359,6 +343,14 @@ } ], "__v": 0 + }, + { + "_id": {"$oid":"200000000000000000000003"}, + "name": "raw material", + "version": 1, + "parameters": [ + ], + "__v": 0 } ], "measurement_templates": [ diff --git a/src/test/helper.ts b/src/test/helper.ts index 3983959..c724d14 100644 --- a/src/test/helper.ts +++ b/src/test/helper.ts @@ -10,6 +10,7 @@ export default class TestHelper { user: {pass: 'Xyz890*)', key: '000000000000000000001001'}, johnnydoe: {pass: 'Xyz890*)', key: '000000000000000000001004'} } + public static res = { // default responses 400: {status: 'Bad request'}, 401: {status: 'Unauthorized'}, From d004a01b69cad56a93625cd446916595daa4b659 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Wed, 27 May 2020 17:03:03 +0200 Subject: [PATCH 44/83] adapted /measurements to use sample_id --- api/api.yaml | 1 - api/schemas.yaml | 2 +- src/index.ts | 1 + src/models/measurement.ts | 2 +- src/routes/measurement.spec.ts | 136 +++++++++++++++++++---------- src/routes/measurement.ts | 54 ++++++++---- src/routes/user.spec.ts | 4 +- src/routes/validate/measurement.ts | 5 +- src/routes/validate/parameters.ts | 5 +- src/routes/validate/template.ts | 1 - src/test/db.json | 21 +++++ 11 files changed, 160 insertions(+), 72 deletions(-) diff --git a/api/api.yaml b/api/api.yaml index c0a5441..9090378 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -54,7 +54,6 @@ tags: - name: / - name: /sample - name: /material - - name: /condition - name: /measurement - name: /template - name: /model diff --git a/api/schemas.yaml b/api/schemas.yaml index 6e1eeb7..e76cfb0 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -133,7 +133,7 @@ Measurement: allOf: - $ref: 'api.yaml#/components/schemas/_Id' properties: - condition_id: + sample_id: $ref: 'api.yaml#/components/schemas/Id' values: type: object diff --git a/src/index.ts b/src/index.ts index 1343442..55ca5ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import db from './db'; // TODO: condition values not needed on initial add // TODO: add multiple samples at once // TODO: coverage +// TODO: think about the display of deleted/new samples and validation in data and UI // tell if server is running in debug or production environment console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); diff --git a/src/models/measurement.ts b/src/models/measurement.ts index 7db0a50..4282b29 100644 --- a/src/models/measurement.ts +++ b/src/models/measurement.ts @@ -9,6 +9,6 @@ const MeasurementSchema = new mongoose.Schema({ values: mongoose.Schema.Types.Mixed, measurement_template: {type: mongoose.Schema.Types.ObjectId, ref: MeasurementTemplateModel}, status: Number -}); +}, {minimize: false}); export default mongoose.model('measurement', MeasurementSchema); \ No newline at end of file diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index 8ca49ed..6e58290 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -3,7 +3,7 @@ import MeasurementModel from '../models/measurement'; import TestHelper from "../test/helper"; import globals from '../globals'; -// TODO: allow empty values +// TODO: restore measurements for m/a describe('/measurement', () => { @@ -19,7 +19,7 @@ describe('/measurement', () => { url: '/measurement/800000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 200, - res: {_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} + res: {_id: '800000000000000000000001', sample_id: '400000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} }); }); it('returns the measurement for an API key', done => { @@ -28,7 +28,24 @@ describe('/measurement', () => { url: '/measurement/800000000000000000000001', auth: {key: 'janedoe'}, httpStatus: 200, - res: {_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} + res: {_id: '800000000000000000000001', sample_id: '400000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} + }); + }); + it('returns deleted measurements for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/800000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + res: {_id: '800000000000000000000004', sample_id: '400000000000000000000003', values: {val1: 1}, measurement_template: '300000000000000000000003'} + }); + }); + it('rejects requests for deleted measurements from a write user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/800000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 403 }); }); it('rejects an invalid id', done => { @@ -64,7 +81,7 @@ describe('/measurement', () => { auth: {basic: 'janedoe'}, httpStatus: 200, req: {}, - res: {_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} + res: {_id: '800000000000000000000001', sample_id: '400000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} }); }); it('keeps unchanged values', done => { @@ -76,7 +93,7 @@ describe('/measurement', () => { req: {values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'}); + should(res.body).be.eql({_id: '800000000000000000000001', sample_id: '400000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'}); MeasurementModel.findById('800000000000000000000001').lean().exec((err, data: any) => { if (err) return done(err); should(data).have.property('status',globals.status.validated); @@ -93,7 +110,7 @@ describe('/measurement', () => { req: {values: {'weight %': 0.5}} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '800000000000000000000002', condition_id: '700000000000000000000002', values: {'weight %': 0.5, 'standard deviation': 0.2}, measurement_template: '300000000000000000000002'}); + should(res.body).be.eql({_id: '800000000000000000000002', sample_id: '400000000000000000000002', values: {'weight %': 0.5, 'standard deviation': 0.2}, measurement_template: '300000000000000000000002'}); MeasurementModel.findById('800000000000000000000002').lean().exec((err, data: any) => { if (err) return done(err); should(data).have.property('status',globals.status.validated); @@ -110,10 +127,10 @@ describe('/measurement', () => { req: {values: {dpt: [[1,2],[3,4],[5,6]]}} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[1,2],[3,4],[5,6]]}, measurement_template: '300000000000000000000001'}); + should(res.body).be.eql({_id: '800000000000000000000001', sample_id: '400000000000000000000001', values: {dpt: [[1,2],[3,4],[5,6]]}, measurement_template: '300000000000000000000001'}); MeasurementModel.findById('800000000000000000000001').lean().exec((err, data: any) => { - should(data).have.only.keys('_id', 'condition_id', 'values', 'measurement_template', 'status', '__v'); - should(data.condition_id.toString()).be.eql('700000000000000000000001'); + should(data).have.only.keys('_id', 'sample_id', 'values', 'measurement_template', 'status', '__v'); + should(data.sample_id.toString()).be.eql('400000000000000000000001'); should(data.measurement_template.toString()).be.eql('300000000000000000000001'); should(data).have.property('status',globals.status.new); should(data).have.property('values'); @@ -129,7 +146,17 @@ describe('/measurement', () => { auth: {basic: 'janedoe'}, httpStatus: 200, req: {values: {'weight %': 0.9}}, - res: {_id: '800000000000000000000002', condition_id: '700000000000000000000002', values: {'weight %': 0.9, 'standard deviation': 0.2}, measurement_template: '300000000000000000000002'} + res: {_id: '800000000000000000000002', sample_id: '400000000000000000000002', values: {'weight %': 0.9, 'standard deviation': 0.2}, measurement_template: '300000000000000000000002'} + }); + }); + it('allows keeping empty values empty', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000005', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {values: {'weight %': 0.9}}, + res: {_id: '800000000000000000000005', sample_id: '400000000000000000000002', values: {'weight %': 0.9, 'standard deviation': null}, measurement_template: '300000000000000000000002'} }); }); it('rejects not specified values', done => { @@ -149,7 +176,7 @@ describe('/measurement', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {values: {val1: 4}}, - res: {status: 'Invalid body format', details: '"val1" must be one of [1, 2, 3]'} + res: {status: 'Invalid body format', details: '"val1" must be one of [1, 2, 3, null]'} }); }); it('rejects a value below minimum range', done => { @@ -182,6 +209,16 @@ describe('/measurement', () => { res: {status: 'Invalid body format', details: '"measurement_template" is not allowed'} }); }); + it('rejects a new sample id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {values: {'weight %': 0.9, 'standard deviation': 0.3}, sample_id: '400000000000000000000002'}, + res: {status: 'Invalid body format', details: '"sample_id" is not allowed'} + }); + }); it('rejects editing a measurement for a write user who did not create this measurement', done => { TestHelper.request(server, done, { method: 'put', @@ -198,7 +235,7 @@ describe('/measurement', () => { auth: {basic: 'admin'}, httpStatus: 200, req: {values: {'weight %': 0.9, 'standard deviation': 0.3}}, - res: {_id: '800000000000000000000002', condition_id: '700000000000000000000002', values: {'weight %': 0.9, 'standard deviation': 0.3}, measurement_template: '300000000000000000000002'} + res: {_id: '800000000000000000000002', sample_id: '400000000000000000000002', values: {'weight %': 0.9, 'standard deviation': 0.3}, measurement_template: '300000000000000000000002'} }); }); it('rejects an invalid id', done => { @@ -327,12 +364,12 @@ describe('/measurement', () => { url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} }).end((err, res) => { if (err) return done(err); - should(res.body).have.only.keys('_id', 'condition_id', 'values', 'measurement_template'); + should(res.body).have.only.keys('_id', 'sample_id', 'values', 'measurement_template'); should(res.body).have.property('_id').be.type('string'); - should(res.body).have.property('condition_id', '700000000000000000000001'); + should(res.body).have.property('sample_id', '400000000000000000000001'); should(res.body).have.property('measurement_template', '300000000000000000000002'); should(res.body).have.property('values'); should(res.body.values).have.property('weight %', 0.8); @@ -346,13 +383,13 @@ describe('/measurement', () => { url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 200, - req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} }).end((err, res) => { if (err) return done(err); MeasurementModel.findById(res.body._id).lean().exec((err, data: any) => { if (err) return done(err); - should(data).have.only.keys('_id', 'condition_id', 'values', 'measurement_template', 'status', '__v'); - should(data.condition_id.toString()).be.eql('700000000000000000000001'); + should(data).have.only.keys('_id', 'sample_id', 'values', 'measurement_template', 'status', '__v'); + should(data.sample_id.toString()).be.eql('400000000000000000000001'); should(data.measurement_template.toString()).be.eql('300000000000000000000002'); should(data).have.property('status', 0); should(data).have.property('values'); @@ -362,24 +399,24 @@ describe('/measurement', () => { }); }); }); - it('rejects an invalid condition id', done => { + it('rejects an invalid sample id', done => { TestHelper.request(server, done, { method: 'post', url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {condition_id: '700000000000h00000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, - res: {status: 'Invalid body format', details: '"condition_id" with value "700000000000h00000000001" fails to match the required pattern: /[0-9a-f]{24}/'} + req: {sample_id: '400000000000h00000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"sample_id" with value "400000000000h00000000001" fails to match the required pattern: /[0-9a-f]{24}/'} }); }); - it('rejects a condition id not available', done => { + it('rejects a sample id not available', done => { TestHelper.request(server, done, { method: 'post', url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {condition_id: '000000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, - res: {status: 'Condition id not available'} + req: {sample_id: '000000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Sample id not available'} }); }); it('rejects an invalid measurement_template id', done => { @@ -388,7 +425,7 @@ describe('/measurement', () => { url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '30000000000h000000000002'}, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '30000000000h000000000002'}, res: {status: 'Invalid body format', details: '"measurement_template" with value "30000000000h000000000002" fails to match the required pattern: /[0-9a-f]{24}/'} }); }); @@ -398,7 +435,7 @@ describe('/measurement', () => { url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '000000000000000000000002'}, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '000000000000000000000002'}, res: {status: 'Measurement template not available'} }); }); @@ -408,18 +445,27 @@ describe('/measurement', () => { url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1, xx: 44}, measurement_template: '300000000000000000000002'}, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1, xx: 44}, measurement_template: '300000000000000000000002'}, res: {status: 'Invalid body format', details: '"xx" is not allowed'} }); }); - it('rejects missing values', done => { + it('accepts missing values', done => { TestHelper.request(server, done, { method: 'post', url: '/measurement/new', auth: {basic: 'janedoe'}, - httpStatus: 400, - req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8}, measurement_template: '300000000000000000000002'}, - res: {status: 'Invalid body format', details: '"standard deviation" is required'} + httpStatus: 200, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8}, measurement_template: '300000000000000000000002'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'sample_id', 'values', 'measurement_template'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('sample_id', '400000000000000000000001'); + should(res.body).have.property('measurement_template', '300000000000000000000002'); + should(res.body).have.property('values'); + should(res.body.values).have.property('weight %', 0.8); + should(res.body.values).have.property('standard deviation', null); + done(); }); }); it('rejects a value not in the value range', done => { @@ -428,8 +474,8 @@ describe('/measurement', () => { url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {condition_id: '700000000000000000000001', values: {val1: 4}, measurement_template: '300000000000000000000003'}, - res: {status: 'Invalid body format', details: '"val1" must be one of [1, 2, 3]'} + req: {sample_id: '400000000000000000000001', values: {val1: 4}, measurement_template: '300000000000000000000003'}, + res: {status: 'Invalid body format', details: '"val1" must be one of [1, 2, 3, null]'} }); }); it('rejects a value below minimum range', done => { @@ -438,7 +484,7 @@ describe('/measurement', () => { url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {condition_id: '700000000000000000000001', values: {'weight %': -1, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + req: {sample_id: '400000000000000000000001', values: {'weight %': -1, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, res: {status: 'Invalid body format', details: '"weight %" must be larger than or equal to 0'} }); }); @@ -448,18 +494,18 @@ describe('/measurement', () => { url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 2}, measurement_template: '300000000000000000000002'}, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 2}, measurement_template: '300000000000000000000002'}, res: {status: 'Invalid body format', details: '"standard deviation" must be less than or equal to 0.5'} }); }); - it('rejects a missing condition id', done => { + it('rejects a missing sample id', done => { TestHelper.request(server, done, { method: 'post', url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 400, req: {values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, - res: {status: 'Invalid body format', details: '"condition_id" is required'} + res: {status: 'Invalid body format', details: '"sample_id" is required'} }); }); it('rejects a missing measurement_template', done => { @@ -468,7 +514,7 @@ describe('/measurement', () => { url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}}, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}}, res: {status: 'Invalid body format', details: '"measurement_template" is required'} }); }); @@ -478,7 +524,7 @@ describe('/measurement', () => { url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 403, - req: {condition_id: '700000000000000000000003', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + req: {sample_id: '400000000000000000000003', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} }); }); it('accepts adding a measurement to the sample of another user for a maintain/admin user', done => { @@ -487,12 +533,12 @@ describe('/measurement', () => { url: '/measurement/new', auth: {basic: 'admin'}, httpStatus: 200, - req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} }).end((err, res) => { if (err) return done(err); - should(res.body).have.only.keys('_id', 'condition_id', 'values', 'measurement_template'); + should(res.body).have.only.keys('_id', 'sample_id', 'values', 'measurement_template'); should(res.body).have.property('_id').be.type('string'); - should(res.body).have.property('condition_id', '700000000000000000000001'); + should(res.body).have.property('sample_id', '400000000000000000000001'); should(res.body).have.property('measurement_template', '300000000000000000000002'); should(res.body).have.property('values'); should(res.body.values).have.property('weight %', 0.8); @@ -506,7 +552,7 @@ describe('/measurement', () => { url: '/measurement/new', auth: {key: 'janedoe'}, httpStatus: 401, - req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} }); }); it('rejects requests from a read user', done => { @@ -515,7 +561,7 @@ describe('/measurement', () => { url: '/measurement/new', auth: {basic: 'user'}, httpStatus: 403, - req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} }); }); it('rejects unauthorized requests', done => { @@ -523,7 +569,7 @@ describe('/measurement', () => { method: 'post', url: '/measurement/new', httpStatus: 401, - req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} }); }); }); diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index b9af125..78b7ec1 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -3,6 +3,7 @@ import _ from 'lodash'; import MeasurementModel from '../models/measurement'; import MeasurementTemplateModel from '../models/measurement_template'; +import SampleModel from '../models/sample'; import MeasurementValidate from './validate/measurement'; import IdValidate from './validate/id'; import res400 from './validate/res400'; @@ -15,11 +16,12 @@ const router = express.Router(); router.get('/measurement/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - MeasurementModel.findById(req.params.id).lean().exec((err, data) => { + MeasurementModel.findById(req.params.id).lean().exec((err, data: any) => { if (err) return next(err); if (!data) { return res.status(404).json({status: 'Not found'}); } + if (data.status ===globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted measurements only available for maintain/admin res.json(MeasurementValidate.output(data)); }); @@ -37,13 +39,13 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { res.status(404).json({status: 'Not found'}); } - // add properties needed for conditionIdCheck + // add properties needed for sampleIdCheck measurement.measurement_template = data.measurement_template; - measurement.condition_id = data.condition_id; - if (!await conditionIdCheck(measurement, req, res, next)) return; + measurement.sample_id = data.sample_id; + if (!await sampleIdCheck(measurement, req, res, next)) return; // check for changes - if (measurement.values) { + if (measurement.values) { // fill not changed values from database measurement.values = _.assign({}, data.values, measurement.values); if (!_.isEqual(measurement.values, data.values)) { measurement.status = globals.status.new; // set status to new @@ -53,6 +55,7 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { if (!await templateCheck(measurement, 'change', res, next)) return; await MeasurementModel.findByIdAndUpdate(req.params.id, measurement, {new: true}).lean().exec((err, data) => { if (err) return next(err); + console.log(data); res.json(MeasurementValidate.output(data)); }); }); @@ -65,7 +68,7 @@ router.delete('/measurement/' + IdValidate.parameter(), (req, res, next) => { if (!data) { res.status(404).json({status: 'Not found'}); } - if (!await conditionIdCheck(data, req, res, next)) return; + if (!await sampleIdCheck(data, req, res, next)) return; await MeasurementModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).lean().exec(err => { if (err) return next(err); res.json({status: 'OK'}); @@ -79,12 +82,14 @@ router.post('/measurement/new', async (req, res, next) => { const {error, value: measurement} = MeasurementValidate.input(req.body, 'new'); if (error) return res400(error, res); - if (!await conditionIdCheck(measurement, req, res, next)) return; - if (!await templateCheck(measurement, 'new', res, next)) return; + if (!await sampleIdCheck(measurement, req, res, next)) return; + measurement.values = await templateCheck(measurement, 'new', res, next); + if (!measurement.values) return; measurement.status = 0; await new MeasurementModel(measurement).save((err, data) => { if (err) return next(err); + console.log(data); res.json(MeasurementValidate.output(data.toObject())); }); }); @@ -93,25 +98,38 @@ router.post('/measurement/new', async (req, res, next) => { module.exports = router; -async function conditionIdCheck (measurement, req, res, next) { // validate condition_id, returns false if invalid // TODO - // const sampleData = await ConditionModel.findById(measurement.condition_id).populate('sample_id').lean().exec().catch(err => {next(err); return false;}) as any; - // if (!sampleData) { // sample_id not found - // res.status(400).json({status: 'Condition id not available'}); +async function sampleIdCheck (measurement, req, res, next) { // validate sample_id, returns false if invalid or user has no access for this sample + const sampleData = await SampleModel.findById(measurement.sample_id).lean().exec().catch(err => {next(err); return false;}) as any; + if (!sampleData) { // sample_id not found + res.status(400).json({status: 'Sample id not available'}); return false - // } - // if (sampleData.sample_id.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return false; // sample does not belong to user - // return true; + } + if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return false; // sample does not belong to user + return true; } -async function templateCheck (measurement, param, res, next) { // validate measurement_template and values, param for new/change +async function templateCheck (measurement, param, res, next) { // validate measurement_template and values, returns values, true if values are {} or false if invalid, param for 'new'/'change' const templateData = await MeasurementTemplateModel.findById(measurement.measurement_template).lean().exec().catch(err => {next(err); return false;}) as any; if (!templateData) { // template not found res.status(400).json({status: 'Measurement template not available'}); return false } + // fill not given values for new measurements + if (param === 'new') { + if (Object.keys(measurement.values).length === 0) { + res.status(400).json({status: 'At least one value is required'}); + return false + } + const fillValues = {}; // initialize not given values with null + templateData.parameters.forEach(parameter => { + fillValues[parameter.name] = null; + }); + measurement.values = _.assign({}, fillValues, measurement.values); + } + // validate values - const {error, value: ignore} = ParametersValidate.input(measurement.values, templateData.parameters, param); + const {error, value} = ParametersValidate.input(measurement.values, templateData.parameters, 'null'); if (error) {res400(error, res); return false;} - return true; + return value || true; } \ No newline at end of file diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index 6a7d69e..a7a2ddb 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -288,7 +288,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {pass: 'password'}, - res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$/'} + res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$)[a-zA-Z0-9!"#%&\'()*+,\\-.\\/:;<=>?@[\\]^_`{|}~]{8,}$/'} }); }); it('rejects requests from non-admins for another user', done => { @@ -546,7 +546,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'password', level: 'read', location: 'Rng', device_name: 'Alpha II'}, - res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$/'} + res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$)[a-zA-Z0-9!"#%&\'()*+,\\-.\\/:;<=>?@[\\]^_`{|}~]{8,}$/'} }); }); it('rejects requests from non-admins', done => { diff --git a/src/routes/validate/measurement.ts b/src/routes/validate/measurement.ts index 21b38a2..74c2409 100644 --- a/src/routes/validate/measurement.ts +++ b/src/routes/validate/measurement.ts @@ -12,13 +12,14 @@ export default class MeasurementValidate { Joi.boolean(), Joi.array() ) + .allow(null) ) }; static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { return Joi.object({ - condition_id: IdValidate.get().required(), + sample_id: IdValidate.get().required(), values: this.measurement.values.required(), measurement_template: IdValidate.get().required() }).validate(data); @@ -37,7 +38,7 @@ export default class MeasurementValidate { data = IdValidate.stringify(data); const {value, error} = Joi.object({ _id: IdValidate.get(), - condition_id: IdValidate.get(), + sample_id: IdValidate.get(), values: this.measurement.values, measurement_template: IdValidate.get() }).validate(data, {stripUnknown: true}); diff --git a/src/routes/validate/parameters.ts b/src/routes/validate/parameters.ts index 79e62ef..e6070b0 100644 --- a/src/routes/validate/parameters.ts +++ b/src/routes/validate/parameters.ts @@ -1,7 +1,7 @@ import Joi from '@hapi/joi'; export default class ParametersValidate { - static input (data, parameters, param) { // data to validate, parameters from template, param: 'new', 'change' + static input (data, parameters, param) { // data to validate, parameters from template, param: 'new', 'change', 'null'(null values are allowed) let joiObject = {}; parameters.forEach(parameter => { if (parameter.range.hasOwnProperty('values')) { // append right validation method according to parameter @@ -39,6 +39,9 @@ export default class ParametersValidate { if (param === 'new') { joiObject[parameter.name] = joiObject[parameter.name].required() } + else if (param === 'null') { + joiObject[parameter.name] = joiObject[parameter.name].allow(null) + } }); return Joi.object(joiObject).validate(data); } diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts index 6b96a42..111951e 100644 --- a/src/routes/validate/template.ts +++ b/src/routes/validate/template.ts @@ -10,7 +10,6 @@ export default class TemplateValidate { .min(1), parameters: Joi.array() - .min(1) .items( Joi.object({ name: Joi.string() diff --git a/src/test/db.json b/src/test/db.json index 7760208..372b09a 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -305,6 +305,27 @@ "status": 0, "measurement_template": {"$oid":"300000000000000000000003"}, "__v": 0 + }, + { + "_id": {"$oid":"800000000000000000000004"}, + "sample_id": {"$oid":"400000000000000000000003"}, + "values": { + "val1": 1 + }, + "status": -1, + "measurement_template": {"$oid":"300000000000000000000003"}, + "__v": 0 + }, + { + "_id": {"$oid":"800000000000000000000005"}, + "sample_id": {"$oid":"400000000000000000000002"}, + "values": { + "weight %": 0.5, + "standard deviation":null + }, + "status": 10, + "measurement_template": {"$oid":"300000000000000000000002"}, + "__v": 0 } ], "condition_templates": [ From 0ea28fa50a376d5b7525380409b7005223645ac4 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 28 May 2020 11:47:51 +0200 Subject: [PATCH 45/83] implemented code coverage --- package.json | 2 +- src/db.ts | 25 +++++++++++++++------ src/index.ts | 1 + src/routes/material.spec.ts | 2 +- src/routes/measurement.spec.ts | 5 ++++- src/routes/measurement.ts | 8 +++---- src/routes/root.spec.ts | 40 +++++++++++++++++++++++++++++++++- src/routes/sample.spec.ts | 3 ++- src/routes/template.spec.ts | 1 + src/routes/template.ts | 2 +- src/routes/user.spec.ts | 1 + src/test/helper.ts | 7 ++++++ 12 files changed, 79 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 5763fdc..6e7f289 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "start": "tsc && node dist/index.js || exit 1", "dev": "nodemon -e ts,yaml --exec \"npm run start\"", "loadDev": "node dist/test/loadDev.js", - "coverage": "nyc --reporter=html --reporter=tex mocha dist/**/**.spec.js" + "coverage": "tsc && nyc --reporter=html --reporter=text mocha dist/**/**.spec.js --timeout 5000" }, "keywords": [], "author": "", diff --git a/src/db.ts b/src/db.ts index c1d1fbb..fb5d424 100644 --- a/src/db.ts +++ b/src/db.ts @@ -42,15 +42,18 @@ export default class db { }); mongoose.connection.on('error', console.error.bind(console, 'connection error:')); mongoose.connection.on('disconnected', () => { // reset state on disconnect - console.info('Database disconnected'); - this.state.db = 0; - done(); + if (process.env.NODE_ENV !== 'test') { // Do not interfere with testing + console.info('Database disconnected'); + this.state.db = 0; + } }); process.on('SIGINT', () => { // close connection when app is terminated - mongoose.connection.close(() => { - console.info('Mongoose default connection disconnected through app termination'); - process.exit(0); - }); + if (!this.state.db) { // database still connected + mongoose.connection.close(() => { + console.info('Mongoose default connection disconnected through app termination'); + process.exit(0); + }); + } }); mongoose.connection.once('open', () => { mongoose.set('useFindAndModify', false); @@ -60,6 +63,14 @@ export default class db { }); } + static disconnect (done) { + mongoose.connection.close(() => { + console.info(process.env.NODE_ENV === 'test' ? '' : `Disconnected from database`); + this.state.db = 0; + done(); + }); + } + static getState () { return this.state; } diff --git a/src/index.ts b/src/index.ts index 55ca5ff..c007ca9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import db from './db'; // TODO: add multiple samples at once // TODO: coverage // TODO: think about the display of deleted/new samples and validation in data and UI +// TODO: improve error coverage // tell if server is running in debug or production environment console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 344642d..ae8d305 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -12,6 +12,7 @@ describe('/material', () => { before(done => TestHelper.before(done)); beforeEach(done => server = TestHelper.beforeEach(server, done)); afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); describe('GET /materials', () => { it('returns all materials', done => { @@ -146,7 +147,6 @@ describe('/material', () => { } }); }); - done(); }); }); it('rejects requests from a write user', done => { diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index 6e58290..e1f36a4 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -5,14 +5,17 @@ import globals from '../globals'; // TODO: restore measurements for m/a +// TODO: coverage!!! + describe('/measurement', () => { let server; before(done => TestHelper.before(done)); beforeEach(done => server = TestHelper.beforeEach(server, done)); afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); - describe('GET /mesurement/{id}', () => { + describe('GET /measurement/{id}', () => { it('returns the right measurement', done => { TestHelper.request(server, done, { method: 'get', diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index 78b7ec1..0d0f0f6 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -36,7 +36,7 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { const data = await MeasurementModel.findById(req.params.id).lean().exec().catch(err => {next(err);}) as any; if (data instanceof Error) return; if (!data) { - res.status(404).json({status: 'Not found'}); + return res.status(404).json({status: 'Not found'}); } // add properties needed for sampleIdCheck @@ -55,7 +55,6 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { if (!await templateCheck(measurement, 'change', res, next)) return; await MeasurementModel.findByIdAndUpdate(req.params.id, measurement, {new: true}).lean().exec((err, data) => { if (err) return next(err); - console.log(data); res.json(MeasurementValidate.output(data)); }); }); @@ -66,12 +65,12 @@ router.delete('/measurement/' + IdValidate.parameter(), (req, res, next) => { MeasurementModel.findById(req.params.id).lean().exec(async (err, data) => { if (err) return next(err); if (!data) { - res.status(404).json({status: 'Not found'}); + return res.status(404).json({status: 'Not found'}); } if (!await sampleIdCheck(data, req, res, next)) return; await MeasurementModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).lean().exec(err => { if (err) return next(err); - res.json({status: 'OK'}); + return res.json({status: 'OK'}); }); }); }); @@ -89,7 +88,6 @@ router.post('/measurement/new', async (req, res, next) => { measurement.status = 0; await new MeasurementModel(measurement).save((err, data) => { if (err) return next(err); - console.log(data); res.json(MeasurementValidate.output(data.toObject())); }); }); diff --git a/src/routes/root.spec.ts b/src/routes/root.spec.ts index f8a803f..a5f8f8b 100644 --- a/src/routes/root.spec.ts +++ b/src/routes/root.spec.ts @@ -1,4 +1,5 @@ import TestHelper from "../test/helper"; +import db from '../db'; describe('/', () => { @@ -6,6 +7,7 @@ describe('/', () => { before(done => TestHelper.before(done)); beforeEach(done => server = TestHelper.beforeEach(server, done)); afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); describe('GET /', () => { it('returns the root message', done => { @@ -40,7 +42,15 @@ describe('/', () => { TestHelper.request(server, done, { method: 'get', url: '/authorized', - auth: {name: 'admin', pass: 'Abc123!!'}, + auth: {basic: {name: 'admin', pass: 'Abc123!!'}}, + httpStatus: 401 + }); + }); + it('does not work with incorrect username', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/authorized', + auth: {basic: {name: 'adminxx', pass: 'Abc123!!'}}, httpStatus: 401 }); }); @@ -66,4 +76,32 @@ describe('/', () => { }); }); }); + + describe('An invalid JSON body', () => { + it('is rejected', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/', + httpStatus: 400, + reqType: 'json', + req: '{"xxx"}', + res: {status: 'Invalid JSON body'} + }); + + }); + }); + + describe('A not connected database', () => { + it('resolves to an 500 error', done => { + db.disconnect(() => { + TestHelper.request(server, done, { + method: 'get', + url: '/', + httpStatus: 500 + }); + }); + }); + }); + + // describe('API') // TODO not in production }); \ No newline at end of file diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index f54f5b4..cfeeb7c 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -19,6 +19,7 @@ describe('/sample', () => { before(done => TestHelper.before(done)); beforeEach(done => server = TestHelper.beforeEach(server, done)); afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); describe('GET /samples', () => { it('returns all samples', done => { @@ -197,7 +198,7 @@ describe('/sample', () => { }); }); - it('returns a deleted sample for a maintain/admin user', done => { // TODO: make tests work + it('returns a deleted sample for a maintain/admin user', done => { TestHelper.request(server, done, { method: 'get', url: '/sample/400000000000000000000005', diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index 2ac09ae..54adfcb 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -13,6 +13,7 @@ describe('/template', () => { before(done => TestHelper.before(done)); beforeEach(done => server = TestHelper.beforeEach(server, done)); afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); describe('/template/condition', () => { describe('GET /template/conditions', () => { diff --git a/src/routes/template.ts b/src/routes/template.ts index f4054c1..849cf59 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -44,7 +44,7 @@ router.put('/template/:collection(measurement|condition)/' + IdValidate.paramete const templateData = await model(req).findById(req.params.id).lean().exec().catch(err => {next(err);}) as any; if (templateData instanceof Error) return; if (!templateData) { - res.status(404).json({status: 'Not found'}); + return res.status(404).json({status: 'Not found'}); } if (!_.isEqual(_.pick(templateData, _.keys(template)), template)) { // data was changed diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index a7a2ddb..a0d67a5 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -9,6 +9,7 @@ describe('/user', () => { before(done => TestHelper.before(done)); beforeEach(done => server = TestHelper.beforeEach(server, done)); afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); describe('GET /users', () => { it('returns all users', done => { diff --git a/src/test/helper.ts b/src/test/helper.ts index c724d14..539eba3 100644 --- a/src/test/helper.ts +++ b/src/test/helper.ts @@ -39,6 +39,10 @@ export default class TestHelper { server.close(done); } + static after(done) { + db.disconnect(done); + } + static request (server, done, options) { // options in form: {method, url, auth: {key/basic: 'name' or 'key'/{name, pass}}, httpStatus, req, res} let st = supertest(server); if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('key')) { // resolve API key @@ -58,6 +62,9 @@ export default class TestHelper { st = st.delete(options.url) break; } + if (options.hasOwnProperty('reqType')) { // request body + st = st.type(options.reqType); + } if (options.hasOwnProperty('req')) { // request body st = st.send(options.req); } From 8276e5108c86e92739ffd830b056a3884ce35cf3 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 28 May 2020 12:18:38 +0200 Subject: [PATCH 46/83] /api/ subroutes only available in dev/test --- src/index.ts | 10 +++++---- src/models/measurement.ts | 2 +- src/routes/measurement.spec.ts | 2 -- src/routes/root.spec.ts | 37 ++++++++++++++++++++++++++++++++-- src/test/helper.ts | 2 +- 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index c007ca9..0de6ff4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,10 +52,12 @@ app.use((req, res, next) => { // no database connection error app.use(require('./helpers/authorize')); // handle authentication // redirect /api routes for Angular proxy in development -app.use('/api/:url', (req, res) => { - req.url = '/' + req.params.url; - app.handle(req, res); -}); +if (process.env.NODE_ENV !== 'production') { + app.use('/api/:url', (req, res) => { + req.url = '/' + req.params.url; + app.handle(req, res); + }); +} // require routes diff --git a/src/models/measurement.ts b/src/models/measurement.ts index 4282b29..d003ea5 100644 --- a/src/models/measurement.ts +++ b/src/models/measurement.ts @@ -2,7 +2,7 @@ import mongoose from 'mongoose'; import SampleModel from './sample'; import MeasurementTemplateModel from './measurement_template'; -// TODO: change to sample_id + const MeasurementSchema = new mongoose.Schema({ sample_id: {type: mongoose.Schema.Types.ObjectId, ref: SampleModel}, diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index e1f36a4..5af91a3 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -5,8 +5,6 @@ import globals from '../globals'; // TODO: restore measurements for m/a -// TODO: coverage!!! - describe('/measurement', () => { let server; diff --git a/src/routes/root.spec.ts b/src/routes/root.spec.ts index a5f8f8b..569af8b 100644 --- a/src/routes/root.spec.ts +++ b/src/routes/root.spec.ts @@ -91,7 +91,7 @@ describe('/', () => { }); }); - describe('A not connected database', () => { + describe('A not connected database', () => { // RUN AS LAST OR RECONNECT DATABASE!! it('resolves to an 500 error', done => { db.disconnect(() => { TestHelper.request(server, done, { @@ -102,6 +102,39 @@ describe('/', () => { }); }); }); +}); - // describe('API') // TODO not in production +describe('The /api/{url} redirect', () => { + let server; + let counter = 0; // count number of current test method + before(done => { + process.env.port = '2999'; + db.connect('test', done); + }); + beforeEach(done => { + process.env.NODE_ENV = counter === 1 ? 'production' : 'test'; + counter ++; + server = TestHelper.beforeEach(server, done); + }); + afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); + + + it('returns the right method', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/api/authorized', + auth: {basic: 'admin'}, + httpStatus: 200, + res: {status: 'Authorization successful', method: 'basic'} + }); + }); + it('is disabled in production', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/api/authorized', + auth: {basic: 'admin'}, + httpStatus: 404 + }); + }); }); \ No newline at end of file diff --git a/src/test/helper.ts b/src/test/helper.ts index 539eba3..fbb45ff 100644 --- a/src/test/helper.ts +++ b/src/test/helper.ts @@ -43,7 +43,7 @@ export default class TestHelper { db.disconnect(done); } - static request (server, done, options) { // options in form: {method, url, auth: {key/basic: 'name' or 'key'/{name, pass}}, httpStatus, req, res} + static request (server, done, options) { // options in form: {method, url, auth: {key/basic: 'name' or 'key'/{name, pass}}, httpStatus, req, res, default (set to false if you want to dismiss default .end handling)} let st = supertest(server); if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('key')) { // resolve API key options.url += '?key=' + (this.auth.hasOwnProperty(options.auth.key)? this.auth[options.auth.key].key : options.auth.key); From 54168e45008692cd9102f3da40be7b205318bee0 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 28 May 2020 12:40:37 +0200 Subject: [PATCH 47/83] adjusted PUT /sample/{id} --- api/sample.yaml | 2 +- src/routes/material.spec.ts | 2 ++ src/routes/sample.spec.ts | 17 ++++++++++------- src/routes/sample.ts | 3 +++ 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/api/sample.yaml b/api/sample.yaml index 9e830ff..cea8de7 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -69,7 +69,7 @@ put: summary: change sample description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to edit samples created by another user' - x-doc: status is reset to 0 on any changes + x-doc: status is reset to 0 on any changes, deleted samples cannot be changed # TODO tags: - /sample security: diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index ae8d305..6c70e07 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -7,6 +7,8 @@ import globals from '../globals'; // TODO: color name must be unique to get color number // TODO: separate supplier/ material name into own collections +// TODO: restore material + describe('/material', () => { let server; before(done => TestHelper.before(done)); diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index cfeeb7c..f0bbe88 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -10,9 +10,9 @@ import globals from '../globals'; // TODO: write script for data import // TODO: delete everything (measurements, condition) with sample // TODO: allow adding sample numbers for existing samples - // TODO: Do not allow validation or measurement entry without condition +// TODO: restore sample describe('/sample', () => { let server; @@ -187,7 +187,6 @@ describe('/sample', () => { res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, user: 'admin'} }); }); - it('works with an API key', done => { TestHelper.request(server, done, { method: 'get', @@ -197,7 +196,6 @@ describe('/sample', () => { res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, user: 'admin'} }); }); - it('returns a deleted sample for a maintain/admin user', done => { TestHelper.request(server, done, { method: 'get', @@ -207,7 +205,6 @@ describe('/sample', () => { res: {_id: '400000000000000000000005', number: 'Rng33', type: 'granulate', color: 'black', batch: '1653000308', condition: {condition_template: '200000000000000000000003'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {}, user: 'admin'} }); }); - it('returns 403 for a write user when requesting a deleted sample', done => { TestHelper.request(server, done, { method: 'get', @@ -216,7 +213,6 @@ describe('/sample', () => { httpStatus: 403 }); }); - it('returns 404 for an unknown sample', done => { TestHelper.request(server, done, { method: 'get', @@ -225,7 +221,6 @@ describe('/sample', () => { httpStatus: 404 }); }); - it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'get', @@ -234,7 +229,6 @@ describe('/sample', () => { httpStatus: 404 }); }); - it('rejects unauthorized requests', done => { TestHelper.request(server, done, { method: 'get', @@ -589,6 +583,15 @@ describe('/sample', () => { res: {status: 'Condition template not available'} }); }); + it('rejects editing a deleted sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 403, + req: {} + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', diff --git a/src/routes/sample.ts b/src/routes/sample.ts index ed1afb3..3976231 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -69,6 +69,9 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!sampleData) { return res.status(404).json({status: 'Not found'}); } + if (sampleData.status === globals.status.deleted) { + return res.status(403).json({status: 'Forbidden'}); + } // only maintain and admin are allowed to edit other user's data if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; From d58026426c8e3b5cdcce5006065791b8b4e119e5 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 28 May 2020 13:05:00 +0200 Subject: [PATCH 48/83] delete measurements with sample --- api/sample.yaml | 2 +- src/routes/sample.spec.ts | 23 +++++++++++++++++++++-- src/routes/sample.ts | 30 ++++++++++++++++++------------ 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/api/sample.yaml b/api/sample.yaml index cea8de7..9f52f77 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -69,7 +69,7 @@ put: summary: change sample description: 'Auth: basic, levels: write, maintain, dev, admin
    Only maintain and admin are allowed to edit samples created by another user' - x-doc: status is reset to 0 on any changes, deleted samples cannot be changed # TODO + x-doc: status is reset to 0 on any changes, deleted samples cannot be changed tags: - /sample security: diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index f0bbe88..1bbfd5d 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -2,13 +2,15 @@ import should from 'should/as-function'; import SampleModel from '../models/sample'; import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; +import MeasurementModel from '../models/measurement'; import TestHelper from "../test/helper"; import globals from '../globals'; +import mongoose from 'mongoose'; // TODO: generate output for ML in format DPT -> data, implement filtering, field selection +// TODO: generate csv // TODO: filter by not completely filled/no measurements // TODO: write script for data import -// TODO: delete everything (measurements, condition) with sample // TODO: allow adding sample numbers for existing samples // TODO: Do not allow validation or measurement entry without condition @@ -752,6 +754,24 @@ describe('/sample', () => { }); }); }); + it('deletes associated measurements', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + MeasurementModel.find({sample_id: mongoose.Types.ObjectId('400000000000000000000001')}).lean().exec((err, data: any) => { + if (err) return done(err); + should(data).matchEach(sample => { + should(sample).have.property('status', -1); + }); + done(); + }); + }); + }); it('rejects deleting samples of other users for write users', done => { TestHelper.request(server, done, { method: 'delete', @@ -768,7 +788,6 @@ describe('/sample', () => { httpStatus: 404 }); }); - it('rejects deleting a sample referenced by conditions'); // TODO after decision it('rejects requests from a read user', done => { TestHelper.request(server, done, { method: 'delete', diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 3976231..9166bbb 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -10,7 +10,7 @@ import MaterialModel from '../models/material'; import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; import IdValidate from './validate/id'; -import mongoose from "mongoose"; +import mongoose from 'mongoose'; import ConditionTemplateModel from '../models/condition_template'; import ParametersValidate from './validate/parameters'; import globals from '../globals'; @@ -141,18 +141,24 @@ router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { await SampleModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).lean().exec(err => { // set sample status if (err) return next(err); - if (sampleData.note_id !== null) { // handle notes - NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { // find notes to update note_fields - if (err) return next(err); - if (data.hasOwnProperty('custom_fields')) { // update note_fields - customFieldsChange(Object.keys(data.custom_fields), -1); - } + + // set status of associated measurements also to deleted + MeasurementModel.update({sample_id: mongoose.Types.ObjectId(req.params.id)}, {status: -1}).lean().exec(err => { + if (err) return next(err); + + if (sampleData.note_id !== null) { // handle notes + NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { // find notes to update note_fields + if (err) return next(err); + if (data.hasOwnProperty('custom_fields')) { // update note_fields + customFieldsChange(Object.keys(data.custom_fields), -1); + } + res.json({status: 'OK'}); + }); + } + else { res.json({status: 'OK'}); - }); - } - else { - res.json({status: 'OK'}); - } + } + }); }); }); }); From c4752d12bad8f50bdc49a42ca01eba05f3c3525e Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 28 May 2020 13:16:15 +0200 Subject: [PATCH 49/83] adapted /measurements --- api/measurement.yaml | 4 ++-- src/routes/measurement.spec.ts | 9 +++++++++ src/routes/measurement.ts | 3 +++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/api/measurement.yaml b/api/measurement.yaml index 298b04e..9116a8c 100644 --- a/api/measurement.yaml +++ b/api/measurement.yaml @@ -4,7 +4,7 @@ get: summary: measurement values by id description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: status handling (accessible (only for maintain/admin))? # TODO after decision + x-doc: deleted samples are available only for maintain/admin tags: - /measurement responses: @@ -25,7 +25,7 @@ put: summary: change measurement description: 'Auth: basic, levels: write, maintain, dev, admin' - x-doc: status is reset to 0 on any changes + x-doc: status is reset to 0 on any changes, deleted measurements cannot be edited tags: - /measurement security: diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index 5af91a3..113847f 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -255,6 +255,15 @@ describe('/measurement', () => { httpStatus: 404 }); }); + it('rejects editing a deleted measurement', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 403, + req: {} + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index 0d0f0f6..ab9d50e 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -38,6 +38,9 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { if (!data) { return res.status(404).json({status: 'Not found'}); } + if (data.status === globals.status.deleted) { + return res.status(403).json({status: 'Forbidden'}); + } // add properties needed for sampleIdCheck measurement.measurement_template = data.measurement_template; From 1c2631c6fba9ac1f4230d51536e965b85428b512 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 28 May 2020 14:11:19 +0200 Subject: [PATCH 50/83] adapted /materials --- api/material.yaml | 4 ++-- src/index.ts | 1 + src/routes/material.spec.ts | 26 ++++++++++++++++++++++++++ src/routes/material.ts | 15 +++++++++------ src/routes/sample.ts | 2 +- src/test/db.json | 17 +++++++++++++++++ 6 files changed, 56 insertions(+), 9 deletions(-) diff --git a/api/material.yaml b/api/material.yaml index d184a3f..51af0ef 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -48,7 +48,7 @@ get: summary: get material details description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: status handling (accessible (only for maintain/admin))? # TODO after decision + x-doc: deleted samples are available only for maintain/admin tags: - /material responses: @@ -67,7 +67,7 @@ put: summary: change material description: 'Auth: basic, levels: write, maintain, dev, admin' - x-doc: status is reset to 0 on any changes + x-doc: status is reset to 0 on any changes, deleted samples cannot be changed tags: - /material security: diff --git a/src/index.ts b/src/index.ts index 0de6ff4..7dda199 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import db from './db'; // TODO: coverage // TODO: think about the display of deleted/new samples and validation in data and UI // TODO: improve error coverage +// TODO: guess properties from material name in UI // tell if server is running in debug or production environment console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 6c70e07..330d5b7 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -204,6 +204,23 @@ describe('/material', () => { res: {_id: '100000000000000000000007', name: 'Ultramid A4H', supplier: 'BASF', group: 'PA66', mineral: 0, glass_fiber: 0, carbon_fiber: 0, numbers: [{color: 'black', number: ''}]} }); }); + it('returns a deleted material for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000008', + auth: {basic: 'admin'}, + httpStatus: 200, + res: {_id: '100000000000000000000008', name: 'Latamid 66 H 2 G 30', supplier: 'LATI', group: 'PA66', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'blue', number: '5513943509'}]} + }); + }); + it('returns 403 for a write user when requesting a deleted material', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000008', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'get', @@ -363,6 +380,15 @@ describe('/material', () => { req: {}, }); }); + it('rejects editing a deleted material', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000008', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', diff --git a/src/routes/material.ts b/src/routes/material.ts index 4a1adb8..ffba3ef 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -34,14 +34,14 @@ router.get('/materials/:group(new|deleted)', (req, res, next) => { router.get('/material/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - MaterialModel.findById(req.params.id).lean().exec((err, data) => { + MaterialModel.findById(req.params.id).lean().exec((err, data: any) => { if (err) return next(err); - if (data) { - res.json(MaterialValidate.output(data)); - } - else { - res.status(404).json({status: 'Not found'}); + + if (!data) { + return res.status(404).json({status: 'Not found'}); } + if (data.status === globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted materials only available for maintain/admin + res.json(MaterialValidate.output(data)); }); }); @@ -55,6 +55,9 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { if (!materialData) { return res.status(404).json({status: 'Not found'}); } + if (materialData.status === globals.status.deleted) { + return res.status(403).json({status: 'Forbidden'}); + } if (material.hasOwnProperty('name') && material.name !== materialData.name) { if (!await nameCheck(material, res, next)) return; } diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 9166bbb..23e786a 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -43,7 +43,7 @@ router.get('/sample/' + IdValidate.parameter(), (req, res, next) => { if (err) return next(err); if (sampleData) { - if (sampleData.status ===globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted samples only available for maintain/admin + if (sampleData.status === globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted samples only available for maintain/admin sampleData.material = sampleData.material_id; // map data to right keys sampleData.user = sampleData.user_id.name; sampleData.notes = sampleData.note_id ? sampleData.note_id : {}; diff --git a/src/test/db.json b/src/test/db.json index 372b09a..de4070f 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -268,6 +268,23 @@ ], "status": 0, "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000008"}, + "name": "Latamid 66 H 2 G 30", + "supplier": "LATI", + "group": "PA66", + "mineral": 0, + "glass_fiber": 30, + "carbon_fiber": 0, + "numbers": [ + { + "color": "blue", + "number": "5513943509" + } + ], + "status": -1, + "__v": 0 } ], "measurements": [ From 0fd0cc8da4a0717d13e4b1cde8c84d6bdb2f9a5d Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 28 May 2020 14:41:35 +0200 Subject: [PATCH 51/83] restore samples --- api/sample.yaml | 23 ++++++++++++++++ src/routes/sample.spec.ts | 56 ++++++++++++++++++++++++++++++++++++++- src/routes/sample.ts | 13 +++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/api/sample.yaml b/api/sample.yaml index 9f52f77..67f25ac 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -119,6 +119,29 @@ 500: $ref: 'api.yaml#/components/responses/500' +/sample/restore/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: restore sample + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 0 + tags: + - /sample + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + /sample/new: post: summary: add sample diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 1bbfd5d..b90a722 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -14,7 +14,6 @@ import mongoose from 'mongoose'; // TODO: allow adding sample numbers for existing samples // TODO: Do not allow validation or measurement entry without condition -// TODO: restore sample describe('/sample', () => { let server; @@ -821,6 +820,61 @@ describe('/sample', () => { }); }); + describe('PUT /sample/restore/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + SampleModel.findById('400000000000000000000005').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.new); + done(); + }); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/000000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + httpStatus: 401, + req: {} + }); + }); + }); + describe('POST /sample/new', () => { it('returns the right sample', done => { TestHelper.request(server, done, { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 23e786a..e8ed1f7 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -163,6 +163,19 @@ router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { }); }); +router.put('/sample/restore/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).lean().exec((err, data) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + res.json({status: 'OK'}); + }); +}); + router.post('/sample/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; From 52039317e0166f2799c7cd5ddf065d35eddfec6d Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 28 May 2020 14:54:52 +0200 Subject: [PATCH 52/83] restore materials --- api/material.yaml | 23 +++++++++++++++ src/routes/material.spec.ts | 57 +++++++++++++++++++++++++++++++++++-- src/routes/material.ts | 13 +++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/api/material.yaml b/api/material.yaml index 51af0ef..967071c 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -117,6 +117,29 @@ 500: $ref: 'api.yaml#/components/responses/500' +/material/restore/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: restore material + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 0 + tags: + - /material + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + /material/new: post: summary: add material diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 330d5b7..56f094e 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -7,8 +7,6 @@ import globals from '../globals'; // TODO: color name must be unique to get color number // TODO: separate supplier/ material name into own collections -// TODO: restore material - describe('/material', () => { let server; before(done => TestHelper.before(done)); @@ -496,6 +494,61 @@ describe('/material', () => { }); }); + describe('PUT /material/restore/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + MaterialModel.findById('100000000000000000000008').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.new); + done(); + }); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/000000000000000000000008', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + httpStatus: 401, + req: {} + }); + }); + }); + describe('POST /material/new', () => { it('returns the right material', done => { TestHelper.request(server, done, { diff --git a/src/routes/material.ts b/src/routes/material.ts index ffba3ef..1711eb5 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -95,6 +95,19 @@ router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { }); }); +router.put('/material/restore/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + MaterialModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).lean().exec((err, data) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + res.json({status: 'OK'}); + }); +}); + router.post('/material/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; From d924ee5a8cfc9d1f7644ff4af9de2d5f36c71522 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 28 May 2020 15:03:49 +0200 Subject: [PATCH 53/83] restore measurements --- api/measurement.yaml | 23 ++++++++++++++ src/routes/measurement.spec.ts | 57 ++++++++++++++++++++++++++++++++-- src/routes/measurement.ts | 13 ++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/api/measurement.yaml b/api/measurement.yaml index 9116a8c..3068d97 100644 --- a/api/measurement.yaml +++ b/api/measurement.yaml @@ -77,6 +77,29 @@ 500: $ref: 'api.yaml#/components/responses/500' +/measurement/restore/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: restore measurement + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 0 + tags: + - /measurement + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + /measurement/new: post: summary: add measurement diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index 113847f..c27bf63 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -3,8 +3,6 @@ import MeasurementModel from '../models/measurement'; import TestHelper from "../test/helper"; import globals from '../globals'; -// TODO: restore measurements for m/a - describe('/measurement', () => { let server; @@ -367,6 +365,61 @@ describe('/measurement', () => { }); }); + describe('PUT /measurement/restore/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + MeasurementModel.findById('800000000000000000000004').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.new); + done(); + }); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/000000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + httpStatus: 401, + req: {} + }); + }); + }); + describe('POST /measurement/new', () => { it('returns the right measurement', done => { TestHelper.request(server, done, { diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index ab9d50e..e7f6271 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -78,6 +78,19 @@ router.delete('/measurement/' + IdValidate.parameter(), (req, res, next) => { }); }); +router.put('/measurement/restore/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + MeasurementModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).lean().exec((err, data) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + res.json({status: 'OK'}); + }); +}); + router.post('/measurement/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; From 14ba1655bace362c19df25990693a4a665826dcf Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 28 May 2020 17:05:23 +0200 Subject: [PATCH 54/83] separated groups and suppliers for material GET --- api/material.yaml | 50 ++++++++++++++++- api/parameters.yaml | 2 +- api/sample.yaml | 4 +- src/models/material.ts | 6 +- src/models/material_groups.ts | 7 +++ src/models/material_suppliers.ts | 7 +++ src/routes/material.spec.ts | 91 +++++++++++++++++++++++++++++- src/routes/material.ts | 44 +++++++++++++-- src/routes/measurement.spec.ts | 1 + src/routes/sample.spec.ts | 2 +- src/routes/sample.ts | 4 +- src/routes/validate/material.ts | 10 ++++ src/test/db.json | 96 ++++++++++++++++++++++++++------ 13 files changed, 291 insertions(+), 33 deletions(-) create mode 100644 src/models/material_groups.ts create mode 100644 src/models/material_suppliers.ts diff --git a/api/material.yaml b/api/material.yaml index 967071c..3122e32 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -19,9 +19,9 @@ 500: $ref: 'api.yaml#/components/responses/500' -/materials/{group}: +/materials/{state}: parameters: - - $ref: 'api.yaml#/components/parameters/Group' + - $ref: 'api.yaml#/components/parameters/State' get: summary: lists all new/deleted materials description: 'Auth: basic, levels: maintain, admin' @@ -168,5 +168,51 @@ $ref: 'api.yaml#/components/responses/401' 403: $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' + +/material/groups: + get: + summary: list all existing material groups + description: 'Auth: all, levels: read, write, maintain, dev, admin' + tags: + - /material + responses: + 200: + description: all material groups + content: + application/json: + schema: + type: array + items: + type: string + example: PA66 + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' + +/material/suppliers: + get: + summary: list all existing material suppliers + description: 'Auth: all, levels: read, write, maintain, dev, admin' + tags: + - /material + responses: + 200: + description: all material suppliers + content: + application/json: + schema: + type: array + items: + type: string + example: BASF + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' 500: $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/api/parameters.yaml b/api/parameters.yaml index b4586f7..3cbe49b 100644 --- a/api/parameters.yaml +++ b/api/parameters.yaml @@ -14,7 +14,7 @@ Name: schema: type: string -Group: +State: name: group description: 'possible values: new, deleted' in: path diff --git a/api/sample.yaml b/api/sample.yaml index 67f25ac..00e35ff 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -19,9 +19,9 @@ 500: $ref: 'api.yaml#/components/responses/500' -/samples/{group}: +/samples/{state}: parameters: - - $ref: 'api.yaml#/components/parameters/Group' + - $ref: 'api.yaml#/components/parameters/State' get: summary: all new/deleted samples in overview description: 'Auth: basic, levels: maintain, admin' diff --git a/src/models/material.ts b/src/models/material.ts index 71d6b34..a183020 100644 --- a/src/models/material.ts +++ b/src/models/material.ts @@ -1,9 +1,11 @@ import mongoose from 'mongoose'; +import MaterialSupplierModel from '../models/material_suppliers'; +import MaterialGroupsModel from '../models/material_groups'; const MaterialSchema = new mongoose.Schema({ name: {type: String, index: {unique: true}}, - supplier: String, - group: String, + supplier_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialSupplierModel}, + group_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialGroupsModel}, mineral: String, glass_fiber: String, carbon_fiber: String, diff --git a/src/models/material_groups.ts b/src/models/material_groups.ts new file mode 100644 index 0000000..e9c9861 --- /dev/null +++ b/src/models/material_groups.ts @@ -0,0 +1,7 @@ +import mongoose from 'mongoose'; + +const MaterialGroupsSchema = new mongoose.Schema({ + name: {type: String, index: {unique: true}} +}); + +export default mongoose.model('material_groups', MaterialGroupsSchema); \ No newline at end of file diff --git a/src/models/material_suppliers.ts b/src/models/material_suppliers.ts new file mode 100644 index 0000000..573d397 --- /dev/null +++ b/src/models/material_suppliers.ts @@ -0,0 +1,7 @@ +import mongoose from 'mongoose'; + +const MaterialSuppliersSchema = new mongoose.Schema({ + name: {type: String, index: {unique: true}} +}); + +export default mongoose.model('material_suppliers', MaterialSuppliersSchema); \ No newline at end of file diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 56f094e..31d7137 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -4,7 +4,6 @@ import MaterialModel from '../models/material'; import TestHelper from "../test/helper"; import globals from '../globals'; -// TODO: color name must be unique to get color number // TODO: separate supplier/ material name into own collections describe('/material', () => { @@ -80,7 +79,7 @@ describe('/material', () => { }); }); - describe('GET /materials/{group}', () => { + describe('GET /materials/{state}', () => { it('returns all new materials', done => { TestHelper.request(server, done, { method: 'get', @@ -767,4 +766,92 @@ describe('/material', () => { }); }); }); + + describe('GET /material/groups', () => { + it('returns all groups', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/groups', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.material_groups.length); + should(res.body[0]).be.eql(json.collections.material_groups[0].name); + should(res.body).matchEach(group => { + should(group).be.type('string'); + }); + done(); + }); + }); + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/groups', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.material_groups.length); + should(res.body[0]).be.eql(json.collections.material_groups[0].name); + should(res.body).matchEach(group => { + should(group).be.type('string'); + }); + done(); + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/groups', + httpStatus: 401 + }); + }); + }); + + describe('GET /material/suppliers', () => { + it('returns all suppliers', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/suppliers', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.material_suppliers.length); + should(res.body[0]).be.eql(json.collections.material_suppliers[0].name); + should(res.body).matchEach(supplier => { + should(supplier).be.type('string'); + }); + done(); + }); + }); + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/suppliers', + auth: {key: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.material_suppliers.length); + should(res.body[0]).be.eql(json.collections.material_suppliers[0].name); + should(res.body).matchEach(supplier => { + should(supplier).be.type('string'); + }); + done(); + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/suppliers', + httpStatus: 401 + }); + }); + }); }); \ No newline at end of file diff --git a/src/routes/material.ts b/src/routes/material.ts index 1711eb5..efdd38b 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -4,6 +4,8 @@ import _ from 'lodash'; import MaterialValidate from './validate/material'; import MaterialModel from '../models/material' import SampleModel from '../models/sample'; +import MaterialGroupsModel from '../models/material_groups'; +import MaterialSuppliersModel from '../models/material_suppliers'; import IdValidate from './validate/id'; import res400 from './validate/res400'; import mongoose from 'mongoose'; @@ -16,17 +18,26 @@ const router = express.Router(); router.get('/materials', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - MaterialModel.find({status:globals.status.validated}).lean().exec((err, data) => { + MaterialModel.find({status:globals.status.validated}).populate('group_id').populate('supplier_id').lean().exec((err, data) => { if (err) return next(err); + console.log(data); + data.forEach((material: any) => { // map group and supplier + material.group = material.group_id.name; + material.supplier = material.supplier_id.name; + }); res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors }); }); -router.get('/materials/:group(new|deleted)', (req, res, next) => { +router.get('/materials/:state(new|deleted)', (req, res, next) => { if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; - MaterialModel.find({status: globals.status[req.params.group]}).lean().exec((err, data) => { + MaterialModel.find({status: globals.status[req.params.state]}).populate('group_id').populate('supplier_id').lean().exec((err, data) => { if (err) return next(err); + data.forEach((material: any) => { // map group and supplier + material.group = material.group_id.name; + material.supplier = material.supplier_id.name; + }); res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors }); }); @@ -34,12 +45,15 @@ router.get('/materials/:group(new|deleted)', (req, res, next) => { router.get('/material/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - MaterialModel.findById(req.params.id).lean().exec((err, data: any) => { + MaterialModel.findById(req.params.id).populate('group_id').populate('supplier_id').lean().exec((err, data: any) => { if (err) return next(err); if (!data) { return res.status(404).json({status: 'Not found'}); } + + data.group = data.group_id.name; + data.supplier = data.supplier_id.name; if (data.status === globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted materials only available for maintain/admin res.json(MaterialValidate.output(data)); }); @@ -108,7 +122,7 @@ router.put('/material/restore/' + IdValidate.parameter(), (req, res, next) => { }); }); -router.post('/material/new', async (req, res, next) => { +router.post('/material/new', async (req, res, next) => { // TODO: check supplier and group, also for PUT and DELETE if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; const {error, value: material} = MaterialValidate.input(req.body, 'new'); @@ -123,6 +137,26 @@ router.post('/material/new', async (req, res, next) => { }); }); +router.get('/material/groups', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + MaterialGroupsModel.find().lean().exec((err, data: any) => { + if (err) return next(err); + + res.json(_.compact(data.map(e => MaterialValidate.outputGroups(e.name)))); // validate all and filter null values from validation errors + }); +}); + +router.get('/material/suppliers', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + MaterialSuppliersModel.find().lean().exec((err, data: any) => { + if (err) return next(err); + + res.json(_.compact(data.map(e => MaterialValidate.outputSuppliers(e.name)))); // validate all and filter null values from validation errors + }); +}); + module.exports = router; diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index c27bf63..af21400 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -3,6 +3,7 @@ import MeasurementModel from '../models/measurement'; import TestHelper from "../test/helper"; import globals from '../globals'; +// TODO: test unique material names and produced error code describe('/measurement', () => { let server; diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index b90a722..11a5641 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -84,7 +84,7 @@ describe('/sample', () => { }); }); - describe('GET /samples/{group}', () => { + describe('GET /samples/{state}', () => { it('returns all new samples', done => { TestHelper.request(server, done, { method: 'get', diff --git a/src/routes/sample.ts b/src/routes/sample.ts index e8ed1f7..0155b8c 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -27,10 +27,10 @@ router.get('/samples', (req, res, next) => { }) }); -router.get('/samples/:group(new|deleted)', (req, res, next) => { +router.get('/samples/:state(new|deleted)', (req, res, next) => { if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; - SampleModel.find({status: globals.status[req.params.group]}).lean().exec((err, data) => { + SampleModel.find({status: globals.status[req.params.state]}).lean().exec((err, data) => { if (err) return next(err); res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors }); diff --git a/src/routes/validate/material.ts b/src/routes/validate/material.ts index 805ccd2..225391a 100644 --- a/src/routes/validate/material.ts +++ b/src/routes/validate/material.ts @@ -83,6 +83,16 @@ export default class MaterialValidate { // validate input for material return error !== undefined? null : value; } + static outputGroups (data) {// validate groups output and strip unwanted properties, returns null if not valid + const {value, error} = this.material.group.validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } + + static outputSuppliers (data) {// validate suppliers output and strip unwanted properties, returns null if not valid + const {value, error} = this.material.supplier.validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } + static outputV() { // return output validator return Joi.object({ _id: IdValidate.get(), diff --git a/src/test/db.json b/src/test/db.json index de4070f..b65c0ec 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -149,8 +149,8 @@ { "_id": {"$oid":"100000000000000000000001"}, "name": "Stanyl TW 200 F8", - "supplier": "DSM", - "group": "PA46", + "supplier_id": {"$oid":"110000000000000000000001"}, + "group_id": {"$oid":"900000000000000000000001"}, "mineral": 0, "glass_fiber": 40, "carbon_fiber": 0, @@ -170,8 +170,8 @@ { "_id": {"$oid":"100000000000000000000002"}, "name": "Ultramid T KR 4355 G7", - "supplier": "BASF", - "group": "PA6/6T", + "supplier_id": {"$oid":"110000000000000000000002"}, + "group_id": {"$oid":"900000000000000000000002"}, "mineral": 0, "glass_fiber": 35, "carbon_fiber": 0, @@ -191,8 +191,8 @@ { "_id": {"$oid":"100000000000000000000003"}, "name": "PA GF 50 black (2706)", - "supplier": "Akro-Plastic", - "group": "PA66+PA6I/6T", + "supplier_id": {"$oid":"110000000000000000000003"}, + "group_id": {"$oid":"900000000000000000000003"}, "mineral": 0, "glass_fiber": 0, "carbon_fiber": 0, @@ -204,8 +204,8 @@ { "_id": {"$oid":"100000000000000000000004"}, "name": "Schulamid 66 GF 25 H", - "supplier": "Schulmann", - "group": "PA66", + "supplier_id": {"$oid":"110000000000000000000004"}, + "group_id": {"$oid":"900000000000000000000004"}, "mineral": 0, "glass_fiber": 25, "carbon_fiber": 0, @@ -221,8 +221,8 @@ { "_id": {"$oid":"100000000000000000000005"}, "name": "Amodel A 1133 HS", - "supplier": "Solvay", - "group": "PPA", + "supplier_id": {"$oid":"110000000000000000000005"}, + "group_id": {"$oid":"900000000000000000000005"}, "mineral": 0, "glass_fiber": 33, "carbon_fiber": 0, @@ -238,8 +238,8 @@ { "_id": {"$oid":"100000000000000000000006"}, "name": "PK-HM natural (4773)", - "supplier": "Akro-Plastic", - "group": "PK", + "supplier_id": {"$oid":"110000000000000000000003"}, + "group_id": {"$oid":"900000000000000000000006"}, "mineral": 0, "glass_fiber": 0, "carbon_fiber": 0, @@ -255,8 +255,8 @@ { "_id": {"$oid":"100000000000000000000007"}, "name": "Ultramid A4H", - "supplier": "BASF", - "group": "PA66", + "supplier_id": {"$oid":"110000000000000000000002"}, + "group_id": {"$oid":"900000000000000000000004"}, "mineral": 0, "glass_fiber": 0, "carbon_fiber": 0, @@ -272,8 +272,8 @@ { "_id": {"$oid":"100000000000000000000008"}, "name": "Latamid 66 H 2 G 30", - "supplier": "LATI", - "group": "PA66", + "supplier_id": {"$oid":"110000000000000000000006"}, + "group_id": {"$oid":"900000000000000000000004"}, "mineral": 0, "glass_fiber": 30, "carbon_fiber": 0, @@ -287,6 +287,70 @@ "__v": 0 } ], + "material_groups": [ + { + "_id": {"$oid":"900000000000000000000001"}, + "name": "PA46", + "__v": 0 + }, + { + "_id": {"$oid":"900000000000000000000002"}, + "name": "PA6/6T", + "__v": 0 + }, + { + "_id": {"$oid":"900000000000000000000003"}, + "name": "PA66+PA6I/6T", + "__v": 0 + }, + { + "_id": {"$oid":"900000000000000000000004"}, + "name": "PA66", + "__v": 0 + }, + { + "_id": {"$oid":"900000000000000000000005"}, + "name": "PPA", + "__v": 0 + }, + { + "_id": {"$oid":"900000000000000000000006"}, + "name": "PK", + "__v": 0 + } + ], + "material_suppliers": [ + { + "_id": {"$oid":"110000000000000000000001"}, + "name": "DSM", + "__v": 0 + }, + { + "_id": {"$oid":"110000000000000000000002"}, + "name": "BASF", + "__v": 0 + }, + { + "_id": {"$oid":"110000000000000000000003"}, + "name": "Akro-Plastic", + "__v": 0 + }, + { + "_id": {"$oid":"110000000000000000000004"}, + "name": "Schulmann", + "__v": 0 + }, + { + "_id": {"$oid":"110000000000000000000005"}, + "name": "Solvay", + "__v": 0 + }, + { + "_id": {"$oid":"110000000000000000000006"}, + "name": "LATI", + "__v": 0 + } + ], "measurements": [ { "_id": {"$oid":"800000000000000000000001"}, From 48b1a9da6e8f5ec6bb83dd459b29664c14825208 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 29 May 2020 10:40:17 +0200 Subject: [PATCH 55/83] separated groups and suppliers for material PUT and POST --- src/routes/material.spec.ts | 71 +++++++++++++++++++++++---------- src/routes/material.ts | 66 ++++++++++++++++++++---------- src/routes/template.spec.ts | 1 - src/routes/validate/material.ts | 2 + 4 files changed, 96 insertions(+), 44 deletions(-) diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 31d7137..43a66ae 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -1,10 +1,12 @@ import should from 'should/as-function'; import _ from 'lodash'; import MaterialModel from '../models/material'; +import MaterialGroupModel from '../models/material_groups'; +import MaterialSupplierModel from '../models/material_suppliers'; import TestHelper from "../test/helper"; import globals from '../globals'; -// TODO: separate supplier/ material name into own collections + describe('/material', () => { let server; @@ -267,7 +269,17 @@ describe('/material', () => { MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { if (err) return done(err); should(data).have.property('status',globals.status.validated); - done(); + MaterialGroupModel.find({name: 'PA46'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]._id.toString()).be.eql('900000000000000000000001'); + MaterialSupplierModel.find({name: 'DSM'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]._id.toString()).be.eql('110000000000000000000001'); + done(); + }); + }); }); }); }); @@ -302,9 +314,21 @@ describe('/material', () => { MaterialModel.findById('100000000000000000000001').lean().exec((err, data:any) => { if (err) return done(err); data._id = data._id.toString(); + data.group_id = data.group_id.toString(); + data.supplier_id = data.supplier_id.toString(); data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); - should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: '0', glass_fiber: '35', carbon_fiber: '0', numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: 0, __v: 0}); - done(); + should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier_id: '110000000000000000000002', group_id: '900000000000000000000002', mineral: '0', glass_fiber: '35', carbon_fiber: '0', numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: 0, __v: 0}); + MaterialGroupModel.find({name: 'PA6/6T'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]._id.toString()).be.eql('900000000000000000000002'); + MaterialSupplierModel.find({name: 'BASF'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]._id.toString()).be.eql('110000000000000000000002'); + done(); + }); + }); }); }); }); @@ -436,8 +460,10 @@ describe('/material', () => { MaterialModel.findById('100000000000000000000002').lean().exec((err, data: any) => { if (err) return done(err); data._id = data._id.toString(); + data.group_id = data.group_id.toString(); + data.supplier_id = data.supplier_id.toString(); data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); - should(data).be.eql({_id: '100000000000000000000002', name: 'Ultramid T KR 4355 G7', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: -1, __v: 0} + should(data).be.eql({_id: '100000000000000000000002', name: 'Ultramid T KR 4355 G7', supplier_id: '110000000000000000000002', group_id: '900000000000000000000002', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: -1, __v: 0} ); done(); }); @@ -583,20 +609,25 @@ describe('/material', () => { req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []} }).end(err => { if (err) return done (err); - MaterialModel.find({name: 'Crastin CE 2510'}).lean().exec((err, data: any) => { + MaterialModel.find({name: 'Crastin CE 2510'}).lean().exec((err, materialData: any) => { if (err) return done (err); - should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v'); - should(data[0]).have.property('_id'); - should(data[0]).have.property('name', 'Crastin CE 2510'); - should(data[0]).have.property('supplier', 'Du Pont'); - should(data[0]).have.property('group', 'PBT'); - should(data[0]).have.property('mineral', '0'); - should(data[0]).have.property('glass_fiber', '30'); - should(data[0]).have.property('carbon_fiber', '0'); - should(data[0]).have.property('status',globals.status.new); - should(data[0].numbers).have.lengthOf(0); - done(); + should(materialData).have.lengthOf(1); + should(materialData[0]).have.only.keys('_id', 'name', 'supplier_id', 'group_id', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v'); + should(materialData[0]).have.property('name', 'Crastin CE 2510'); + should(materialData[0]).have.property('mineral', '0'); + should(materialData[0]).have.property('glass_fiber', '30'); + should(materialData[0]).have.property('carbon_fiber', '0'); + should(materialData[0]).have.property('status',globals.status.new); + should(materialData[0].numbers).have.lengthOf(0); + MaterialGroupModel.findById(materialData[0].group_id).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('name', 'PBT') + MaterialSupplierModel.findById(materialData[0].supplier_id).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('name', 'Du Pont'); + done(); + }); + }); }); }); }); @@ -625,11 +656,9 @@ describe('/material', () => { MaterialModel.find({name: 'Crastin CE 2510'}).lean().exec((err, data: any) => { if (err) return done (err); should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v'); + should(data[0]).have.only.keys('_id', 'name', 'supplier_id', 'group_id', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v'); should(data[0]).have.property('_id'); should(data[0]).have.property('name', 'Crastin CE 2510'); - should(data[0]).have.property('supplier', 'Du Pont'); - should(data[0]).have.property('group', 'PBT'); should(data[0]).have.property('mineral', '0'); should(data[0]).have.property('glass_fiber', '30'); should(data[0]).have.property('carbon_fiber', '0'); diff --git a/src/routes/material.ts b/src/routes/material.ts index efdd38b..2d95607 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -4,8 +4,8 @@ import _ from 'lodash'; import MaterialValidate from './validate/material'; import MaterialModel from '../models/material' import SampleModel from '../models/sample'; -import MaterialGroupsModel from '../models/material_groups'; -import MaterialSuppliersModel from '../models/material_suppliers'; +import MaterialGroupModel from '../models/material_groups'; +import MaterialSupplierModel from '../models/material_suppliers'; import IdValidate from './validate/id'; import res400 from './validate/res400'; import mongoose from 'mongoose'; @@ -20,11 +20,7 @@ router.get('/materials', (req, res, next) => { MaterialModel.find({status:globals.status.validated}).populate('group_id').populate('supplier_id').lean().exec((err, data) => { if (err) return next(err); - console.log(data); - data.forEach((material: any) => { // map group and supplier - material.group = material.group_id.name; - material.supplier = material.supplier_id.name; - }); + res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors }); }); @@ -34,10 +30,7 @@ router.get('/materials/:state(new|deleted)', (req, res, next) => { MaterialModel.find({status: globals.status[req.params.state]}).populate('group_id').populate('supplier_id').lean().exec((err, data) => { if (err) return next(err); - data.forEach((material: any) => { // map group and supplier - material.group = material.group_id.name; - material.supplier = material.supplier_id.name; - }); + res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors }); }); @@ -52,8 +45,6 @@ router.get('/material/' + IdValidate.parameter(), (req, res, next) => { return res.status(404).json({status: 'Not found'}); } - data.group = data.group_id.name; - data.supplier = data.supplier_id.name; if (data.status === globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted materials only available for maintain/admin res.json(MaterialValidate.output(data)); }); @@ -62,7 +53,7 @@ router.get('/material/' + IdValidate.parameter(), (req, res, next) => { router.put('/material/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; - const {error, value: material} = MaterialValidate.input(req.body, 'change'); + let {error, value: material} = MaterialValidate.input(req.body, 'change'); if (error) return res400(error, res); MaterialModel.findById(req.params.id).lean().exec(async (err, materialData: any) => { @@ -75,13 +66,21 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { if (material.hasOwnProperty('name') && material.name !== materialData.name) { if (!await nameCheck(material, res, next)) return; } + if (material.hasOwnProperty('group')) { + material = await groupResolve(material, next); + if (!material) return; + } + if (material.hasOwnProperty('supplier')) { + material = await supplierResolve(material, next); + if (!material) return; + } // check for changes - if (!_.isEqual(_.pick(IdValidate.stringify(materialData), _.keys(material)), material)) { + if (!_.isEqual(_.pick(IdValidate.stringify(materialData), _.keys(material)), IdValidate.stringify(material))) { material.status = globals.status.new; // set status to new } - await MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).lean().exec((err, data) => { + await MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).populate('group_id').populate('supplier_id').lean().exec((err, data) => { if (err) return next(err); res.json(MaterialValidate.output(data)); }); @@ -97,7 +96,7 @@ router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { if (data.length) { return res.status(400).json({status: 'Material still in use'}); } - MaterialModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).lean().exec((err, data) => { + MaterialModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).populate('group_id').populate('supplier_id').lean().exec((err, data) => { if (err) return next(err); if (data) { res.json({status: 'OK'}); @@ -122,17 +121,24 @@ router.put('/material/restore/' + IdValidate.parameter(), (req, res, next) => { }); }); -router.post('/material/new', async (req, res, next) => { // TODO: check supplier and group, also for PUT and DELETE +router.post('/material/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; - const {error, value: material} = MaterialValidate.input(req.body, 'new'); + let {error, value: material} = MaterialValidate.input(req.body, 'new'); if (error) return res400(error, res); if (!await nameCheck(material, res, next)) return; + material = await groupResolve(material, next); + if (!material) return; + material = await supplierResolve(material, next); + if (!material) return; + material.status = globals.status.new; // set status to new - await new MaterialModel(material).save((err, data) => { + await new MaterialModel(material).save(async (err, data) => { if (err) return next(err); + await data.populate('group_id').populate('supplier_id').execPopulate().catch(err => next(err)); + if (data instanceof Error) return; res.json(MaterialValidate.output(data.toObject())); }); }); @@ -140,7 +146,7 @@ router.post('/material/new', async (req, res, next) => { // TODO: check supplie router.get('/material/groups', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - MaterialGroupsModel.find().lean().exec((err, data: any) => { + MaterialGroupModel.find().lean().exec((err, data: any) => { if (err) return next(err); res.json(_.compact(data.map(e => MaterialValidate.outputGroups(e.name)))); // validate all and filter null values from validation errors @@ -150,7 +156,7 @@ router.get('/material/groups', (req, res, next) => { router.get('/material/suppliers', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - MaterialSuppliersModel.find().lean().exec((err, data: any) => { + MaterialSupplierModel.find().lean().exec((err, data: any) => { if (err) return next(err); res.json(_.compact(data.map(e => MaterialValidate.outputSuppliers(e.name)))); // validate all and filter null values from validation errors @@ -169,4 +175,20 @@ async function nameCheck (material, res, next) { // check if name was already t return false; } return true; +} + +async function groupResolve (material, next) { + const groupData = await MaterialGroupModel.findOneAndUpdate({name: material.group}, {name: material.group}, {upsert: true, new: true}).lean().exec().catch(err => next(err)) as any; + if (groupData instanceof Error) return false; + material.group_id = groupData._id; + delete material.group; + return material; +} + +async function supplierResolve (material, next) { + const supplierData = await MaterialSupplierModel.findOneAndUpdate({name: material.supplier}, {name: material.supplier}, {upsert: true, new: true}).lean().exec().catch(err => next(err)) as any; + if (supplierData instanceof Error) return false; + material.supplier_id = supplierData._id; + delete material.supplier; + return material; } \ No newline at end of file diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index 54adfcb..7f07d1d 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -5,7 +5,6 @@ import TemplateMeasurementModel from '../models/measurement_template'; import TestHelper from "../test/helper"; // TODO: do not allow usage of old templates for new samples -// TODO: remove number_prefix // TODO: template parameters are not allowed to be condition_template describe('/template', () => { diff --git a/src/routes/validate/material.ts b/src/routes/validate/material.ts index 225391a..7a2c3fb 100644 --- a/src/routes/validate/material.ts +++ b/src/routes/validate/material.ts @@ -70,6 +70,8 @@ export default class MaterialValidate { // validate input for material static output (data) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); + data.group = data.group_id.name; + data.supplier = data.supplier_id.name; const {value, error} = Joi.object({ _id: IdValidate.get(), name: this.material.name, From e342224b7f452e392c71a45dd2ccba11ffdc4746 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 29 May 2020 11:06:39 +0200 Subject: [PATCH 56/83] made GET /sample/{id} work with new material model --- src/routes/sample.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 0155b8c..65c0a86 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -39,12 +39,18 @@ router.get('/samples/:state(new|deleted)', (req, res, next) => { router.get('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - SampleModel.findById(req.params.id).populate('material_id').populate('user_id', 'name').populate('note_id').lean().exec((err, sampleData: any) => { + SampleModel.findById(req.params.id).populate('material_id').populate('user_id', 'name').populate('note_id').exec(async (err, sampleData: any) => { if (err) return next(err); if (sampleData) { + await sampleData.populate('material_id.group_id').populate('material_id.supplier_id').execPopulate().catch(err => next(err)); + if (sampleData instanceof Error) return; + sampleData = sampleData.toObject(); + if (sampleData.status === globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted samples only available for maintain/admin sampleData.material = sampleData.material_id; // map data to right keys + sampleData.material.group = sampleData.material.group_id.name; + sampleData.material.supplier = sampleData.material.supplier_id.name; sampleData.user = sampleData.user_id.name; sampleData.notes = sampleData.note_id ? sampleData.note_id : {}; MeasurementModel.find({sample_id: mongoose.Types.ObjectId(req.params.id)}).lean().exec((err, data) => { @@ -143,7 +149,7 @@ router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { if (err) return next(err); // set status of associated measurements also to deleted - MeasurementModel.update({sample_id: mongoose.Types.ObjectId(req.params.id)}, {status: -1}).lean().exec(err => { + MeasurementModel.updateMany({sample_id: mongoose.Types.ObjectId(req.params.id)}, {status: -1}).lean().exec(err => { if (err) return next(err); if (sampleData.note_id !== null) { // handle notes From ea81108251582abdda60db4f7f04203037e06d1f Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 29 May 2020 11:28:35 +0200 Subject: [PATCH 57/83] validation for measurement --- api/measurement.yaml | 25 ++++++++++++++- src/routes/measurement.spec.ts | 56 +++++++++++++++++++++++++++++++++- src/routes/measurement.ts | 24 ++++++++++----- 3 files changed, 96 insertions(+), 9 deletions(-) diff --git a/api/measurement.yaml b/api/measurement.yaml index 3068d97..0c29e77 100644 --- a/api/measurement.yaml +++ b/api/measurement.yaml @@ -100,6 +100,29 @@ 500: $ref: 'api.yaml#/components/responses/500' +/measurement/validate/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: set measurement status to validated + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 10 + tags: + - /measurement + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + /measurement/new: post: summary: add measurement @@ -129,4 +152,4 @@ 403: $ref: 'api.yaml#/components/responses/403' 500: - $ref: 'api.yaml#/components/responses/500' \ No newline at end of file + $ref: 'api.yaml#/components/responses/500' diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index af21400..8bef49b 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -3,7 +3,6 @@ import MeasurementModel from '../models/measurement'; import TestHelper from "../test/helper"; import globals from '../globals'; -// TODO: test unique material names and produced error code describe('/measurement', () => { let server; @@ -421,6 +420,61 @@ describe('/measurement', () => { }); }); + describe('PUT /measurement/validate/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/validate/800000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + MeasurementModel.findById('800000000000000000000003').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.validated); + done(); + }); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/validate/800000000000000000000003', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/validate/800000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/validate/000000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/validate/800000000000000000000003', + httpStatus: 401, + req: {} + }); + }); + }); + describe('POST /measurement/new', () => { it('returns the right measurement', done => { TestHelper.request(server, done, { diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index e7f6271..2b94060 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -81,14 +81,13 @@ router.delete('/measurement/' + IdValidate.parameter(), (req, res, next) => { router.put('/measurement/restore/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; - MeasurementModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).lean().exec((err, data) => { - if (err) return next(err); + setStatus(globals.status.new, req, res, next); +}); - if (!data) { - return res.status(404).json({status: 'Not found'}); - } - res.json({status: 'OK'}); - }); +router.put('/measurement/validate/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + setStatus(globals.status.validated, req, res, next); }); router.post('/measurement/new', async (req, res, next) => { @@ -146,4 +145,15 @@ async function templateCheck (measurement, param, res, next) { // validate meas const {error, value} = ParametersValidate.input(measurement.values, templateData.parameters, 'null'); if (error) {res400(error, res); return false;} return value || true; +} + +function setStatus (status, req, res, next) { // set measurement status + MeasurementModel.findByIdAndUpdate(req.params.id, {status: status}).lean().exec((err, data) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + res.json({status: 'OK'}); + }); } \ No newline at end of file From d93b2ad748fb8606d6b999d9c55bde8b646f7878 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 29 May 2020 12:22:01 +0200 Subject: [PATCH 58/83] validation for sample --- api/sample.yaml | 25 +++++++++++++ src/routes/sample.spec.ts | 76 ++++++++++++++++++++++++++++++++++++++- src/routes/sample.ts | 28 +++++++++++++++ src/test/db.json | 11 ++++++ 4 files changed, 139 insertions(+), 1 deletion(-) diff --git a/api/sample.yaml b/api/sample.yaml index 00e35ff..d074172 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -142,6 +142,31 @@ 500: $ref: 'api.yaml#/components/responses/500' +/sample/validate/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: set sample status to validated + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 10 + tags: + - /sample + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + /sample/new: post: summary: add sample diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 11a5641..f2fdd36 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -12,7 +12,6 @@ import mongoose from 'mongoose'; // TODO: filter by not completely filled/no measurements // TODO: write script for data import // TODO: allow adding sample numbers for existing samples -// TODO: Do not allow validation or measurement entry without condition describe('/sample', () => { @@ -875,6 +874,81 @@ describe('/sample', () => { }); }); + describe('PUT /sample/validate/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + SampleModel.findById('400000000000000000000003').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.validated); + done(); + }); + }); + }); + it('rejects validating a sample without condition', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000006', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {}, + res: {status: 'Sample without condition cannot be valid'} + }); + }); + it('rejects validating a sample without measurements', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {}, + res: {status: 'Sample without measurements cannot be valid'} + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000003', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/000000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000003', + httpStatus: 401, + req: {} + }); + }); + }); + describe('POST /sample/new', () => { it('returns the right sample', done => { TestHelper.request(server, done, { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 65c0a86..e741c4a 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -182,6 +182,34 @@ router.put('/sample/restore/' + IdValidate.parameter(), (req, res, next) => { }); }); +router.put('/sample/validate/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + SampleModel.findById(req.params.id).lean().exec((err, data: any) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + if (Object.keys(data.condition).length === 0) { + return res.status(400).json({status: 'Sample without condition cannot be valid'}); + } + + MeasurementModel.find({sample_id: mongoose.Types.ObjectId(req.params.id)}).lean().exec((err, data) => { + if (err) return next(err); + + if (data.length === 0) { + return res.status(400).json({status: 'Sample without measurements cannot be valid'}); + } + + SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.validated}).lean().exec(err => { + if (err) return next(err); + res.json({status: 'OK'}); + }); + }); + }); +}); + router.post('/sample/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; diff --git a/src/test/db.json b/src/test/db.json index b65c0ec..0d49876 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -407,6 +407,17 @@ "status": 10, "measurement_template": {"$oid":"300000000000000000000002"}, "__v": 0 + }, + { + "_id": {"$oid":"800000000000000000000006"}, + "sample_id": {"$oid":"400000000000000000000006"}, + "values": { + "weight %": 0.5, + "standard deviation":null + }, + "status": 0, + "measurement_template": {"$oid":"300000000000000000000002"}, + "__v": 0 } ], "condition_templates": [ From 90c88983912356e541ebdbf17c8b1bd6dbaa7314 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 29 May 2020 12:54:05 +0200 Subject: [PATCH 59/83] validation for material --- api/material.yaml | 23 ++++++++++++++++ src/routes/material.spec.ts | 55 +++++++++++++++++++++++++++++++++++++ src/routes/material.ts | 24 +++++++++++----- 3 files changed, 95 insertions(+), 7 deletions(-) diff --git a/api/material.yaml b/api/material.yaml index 3122e32..378628d 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -140,6 +140,29 @@ 500: $ref: 'api.yaml#/components/responses/500' +/material/validate/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: restore material + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 10 + tags: + - /material + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + /material/new: post: summary: add material diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 43a66ae..9645d1b 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -574,6 +574,61 @@ describe('/material', () => { }); }); + describe('PUT /material/validate/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/validate/100000000000000000000007', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + MaterialModel.findById('100000000000000000000007').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.validated); + done(); + }); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/validate/100000000000000000000007', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/validate/100000000000000000000007', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/validate/000000000000000000000007', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/validate/100000000000000000000007', + httpStatus: 401, + req: {} + }); + }); + }); + describe('POST /material/new', () => { it('returns the right material', done => { TestHelper.request(server, done, { diff --git a/src/routes/material.ts b/src/routes/material.ts index 2d95607..4be1137 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -111,14 +111,13 @@ router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { router.put('/material/restore/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; - MaterialModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).lean().exec((err, data) => { - if (err) return next(err); + setStatus(globals.status.new, req, res, next); +}); - if (!data) { - return res.status(404).json({status: 'Not found'}); - } - res.json({status: 'OK'}); - }); +router.put('/material/validate/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + setStatus(globals.status.validated, req, res, next); }); router.post('/material/new', async (req, res, next) => { @@ -191,4 +190,15 @@ async function supplierResolve (material, next) { material.supplier_id = supplierData._id; delete material.supplier; return material; +} + +function setStatus (status, req, res, next) { // set measurement status + MaterialModel.findByIdAndUpdate(req.params.id, {status: status}).lean().exec((err, data) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + res.json({status: 'OK'}); + }); } \ No newline at end of file From ea336f4ebcdb97b834020fb199501d10c0c834b7 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 29 May 2020 14:26:39 +0200 Subject: [PATCH 60/83] forbid condition_template as parameter name for template --- src/routes/template.spec.ts | 23 +++++++++++++++++++++-- src/routes/user.spec.ts | 1 - src/routes/validate/template.ts | 1 + 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index 7f07d1d..95141af 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -5,7 +5,6 @@ import TemplateMeasurementModel from '../models/measurement_template'; import TestHelper from "../test/helper"; // TODO: do not allow usage of old templates for new samples -// TODO: template parameters are not allowed to be condition_template describe('/template', () => { let server; @@ -218,6 +217,16 @@ describe('/template', () => { done(); }); }); + it('rejects `condition_template` as parameter name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {parameters: [{name: 'condition_template', range: {}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].name" contains an invalid value'} + }); + }); it('rejects not specified parameters', done => { TestHelper.request(server, done, { method: 'put', @@ -227,7 +236,7 @@ describe('/template', () => { req: {name: 'heat treatment', parameters: [{name: 'material', range: {xx: 5}}]}, res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} }); - }) + }); it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'put', @@ -327,6 +336,16 @@ describe('/template', () => { res: {status: 'Invalid body format', details: '"name" is required'} }); }); + it('rejects `condition_template` as parameter name', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{name: 'condition_template', range: {min: 1}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].name" contains an invalid value'} + }); + }); it('rejects a number prefix', done => { TestHelper.request(server, done, { method: 'post', diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index a0d67a5..917b734 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -2,7 +2,6 @@ import should from 'should/as-function'; import UserModel from '../models/user'; import TestHelper from "../test/helper"; -// TODO: reject usernames containing admin, etc. describe('/user', () => { let server; diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts index 111951e..7a63d1d 100644 --- a/src/routes/validate/template.ts +++ b/src/routes/validate/template.ts @@ -14,6 +14,7 @@ export default class TemplateValidate { Joi.object({ name: Joi.string() .max(128) + .invalid('condition_template') .required(), range: Joi.object({ From 0fcb90249927eb756c5593de4b6a8c92dc9b80e8 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 29 May 2020 15:24:24 +0200 Subject: [PATCH 61/83] introduced first_id to reference new template versions to original --- api/schemas.yaml | 8 -------- api/template.yaml | 12 ++++++------ src/models/condition_template.ts | 1 + src/models/measurement_template.ts | 1 + src/routes/template.spec.ts | 18 ++++++++++++------ src/routes/template.ts | 3 +++ src/test/db.json | 6 ++++++ 7 files changed, 29 insertions(+), 20 deletions(-) diff --git a/api/schemas.yaml b/api/schemas.yaml index e76cfb0..c4814b7 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -165,14 +165,6 @@ Template: min: 0 max: 2 -ConditionTemplate: - allOf: - - $ref: 'api.yaml#/components/schemas/Template' - properties: - number_prefix: - type: string - example: B - Email: properties: email: diff --git a/api/template.yaml b/api/template.yaml index 71a282f..4fa938d 100644 --- a/api/template.yaml +++ b/api/template.yaml @@ -14,7 +14,7 @@ schema: type: array items: - $ref: 'api.yaml#/components/schemas/ConditionTemplate' + $ref: 'api.yaml#/components/schemas/Template' 401: $ref: 'api.yaml#/components/responses/401' 500: @@ -36,7 +36,7 @@ content: application/json: schema: - $ref: 'api.yaml#/components/schemas/ConditionTemplate' + $ref: 'api.yaml#/components/schemas/Template' 401: $ref: 'api.yaml#/components/responses/401' 404: @@ -56,14 +56,14 @@ content: application/json: schema: - $ref: 'api.yaml#/components/schemas/ConditionTemplate' + $ref: 'api.yaml#/components/schemas/Template' responses: 200: description: condition details content: application/json: schema: - $ref: 'api.yaml#/components/schemas/ConditionTemplate' + $ref: 'api.yaml#/components/schemas/Template' 400: $ref: 'api.yaml#/components/responses/400' 401: @@ -88,14 +88,14 @@ content: application/json: schema: - $ref: 'api.yaml#/components/schemas/ConditionTemplate' + $ref: 'api.yaml#/components/schemas/Template' responses: 200: description: condition details content: application/json: schema: - $ref: 'api.yaml#/components/schemas/ConditionTemplate' + $ref: 'api.yaml#/components/schemas/Template' 400: $ref: 'api.yaml#/components/responses/400' 401: diff --git a/src/models/condition_template.ts b/src/models/condition_template.ts index 20c7234..62bf621 100644 --- a/src/models/condition_template.ts +++ b/src/models/condition_template.ts @@ -1,6 +1,7 @@ import mongoose from 'mongoose'; const ConditionTemplateSchema = new mongoose.Schema({ + first_id: mongoose.Schema.Types.ObjectId, name: String, version: Number, parameters: [{ diff --git a/src/models/measurement_template.ts b/src/models/measurement_template.ts index 080f42b..af14fee 100644 --- a/src/models/measurement_template.ts +++ b/src/models/measurement_template.ts @@ -1,6 +1,7 @@ import mongoose from 'mongoose'; const MeasurementTemplateSchema = new mongoose.Schema({ + first_id: mongoose.Schema.Types.ObjectId, name: String, version: Number, parameters: [{ diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index 95141af..e69b480 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -133,7 +133,8 @@ describe('/template', () => { if (err) return done(err); TemplateConditionModel.findById(res.body._id).lean().exec((err, data:any) => { if (err) return done(err); - should(data).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); + should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); + should(data.first_id.toString()).be.eql('200000000000000000000001'); should(data).have.property('name', 'heat aging'); should(data).have.property('version', 2); should(data).have.property('parameters').have.lengthOf(1); @@ -155,7 +156,8 @@ describe('/template', () => { if (err) return done(err); TemplateConditionModel.findById(res.body._id).lean().exec((err, data:any) => { if (err) return done(err); - should(data).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); + should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); + should(data.first_id.toString()).be.eql('200000000000000000000001'); should(data).have.property('name', 'heat aging'); should(data).have.property('version', 2); should(data).have.property('parameters').have.lengthOf(2); @@ -315,7 +317,8 @@ describe('/template', () => { if (err) return done(err); TemplateConditionModel.findById(res.body._id).lean().exec((err, data:any) => { if (err) return done(err); - should(data).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); + should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); + should(data.first_id.toString()).be.eql(data._id.toString()); should(data).have.property('name', 'heat aging'); should(data).have.property('version', 1); should(data).have.property('parameters').have.lengthOf(1); @@ -556,7 +559,8 @@ describe('/template', () => { should(_.omit(res.body, '_id')).be.eql({name: 'IR spectrum', version: 2, parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}); TemplateMeasurementModel.findById(res.body._id).lean().exec((err, data:any) => { if (err) return done(err); - should(data).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); + should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); + should(data.first_id.toString()).be.eql('300000000000000000000001'); should(data).have.property('name', 'IR spectrum'); should(data).have.property('version', 2); should(data).have.property('parameters').have.lengthOf(1); @@ -580,7 +584,8 @@ describe('/template', () => { should(_.omit(res.body, '_id')).be.eql({name: 'IR spectrum', version: 2, parameters: [{name: 'dpt', range: {type: 'array'}}]}); TemplateMeasurementModel.findById(res.body._id).lean().exec((err, data:any) => { if (err) return done(err); - should(data).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); + should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); + should(data.first_id.toString()).be.eql('300000000000000000000001'); should(data).have.property('name', 'IR spectrum'); should(data).have.property('version', 2); should(data).have.property('parameters').have.lengthOf(1); @@ -731,7 +736,8 @@ describe('/template', () => { TemplateMeasurementModel.find({name: 'vz'}).lean().exec((err, data:any) => { if (err) return done(err); should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'version', 'parameters', '__v'); + should(data[0]).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v'); + should(data[0].first_id.toString()).be.eql(data[0]._id.toString()); should(data[0]).have.property('name', 'vz'); should(data[0]).have.property('version', 1); should(data[0]).have.property('parameters').have.lengthOf(1); diff --git a/src/routes/template.ts b/src/routes/template.ts index 849cf59..2873946 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -6,6 +6,7 @@ import ConditionTemplateModel from '../models/condition_template'; import MeasurementTemplateModel from '../models/measurement_template'; import res400 from './validate/res400'; import IdValidate from './validate/id'; +import mongoose from "mongoose"; @@ -65,6 +66,8 @@ router.post('/template/:collection(measurement|condition)/new', async (req, res, const {error, value: template} = TemplateValidate.input(req.body, 'new'); if (error) return res400(error, res); + template._id = mongoose.Types.ObjectId(); // set reference to itself for first version of template + template.first_id = template._id; template.version = 1; // set template version await new (model(req))(template).save((err, data) => { if (err) next (err); diff --git a/src/test/db.json b/src/test/db.json index 0d49876..4ef811c 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -423,6 +423,7 @@ "condition_templates": [ { "_id": {"$oid":"200000000000000000000001"}, + "first_id": {"$oid":"200000000000000000000001"}, "name": "heat treatment", "version": 1, "parameters": [ @@ -447,6 +448,7 @@ }, { "_id": {"$oid":"200000000000000000000002"}, + "first_id": {"$oid":"200000000000000000000001"}, "name": "heat treatment 2", "version": 2, "parameters": [ @@ -459,6 +461,7 @@ }, { "_id": {"$oid":"200000000000000000000003"}, + "first_id": {"$oid":"200000000000000000000003"}, "name": "raw material", "version": 1, "parameters": [ @@ -469,6 +472,7 @@ "measurement_templates": [ { "_id": {"$oid":"300000000000000000000001"}, + "first_id": {"$oid":"300000000000000000000001"}, "name": "spectrum", "version": 1, "parameters": [ @@ -483,6 +487,7 @@ }, { "_id": {"$oid":"300000000000000000000002"}, + "first_id": {"$oid":"300000000000000000000001"}, "name": "kf", "version": 2, "parameters": [ @@ -505,6 +510,7 @@ }, { "_id": {"$oid":"300000000000000000000003"}, + "first_id": {"$oid":"300000000000000000000003"}, "name": "mt 3", "version": 1, "parameters": [ From 74080d0902da66aa2256c79892cd5470d2894ccc Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Tue, 2 Jun 2020 10:24:22 +0200 Subject: [PATCH 62/83] only allowed latest template version and allowed admin to set sample number --- api/sample.yaml | 9 ++++- api/schemas.yaml | 1 + src/index.ts | 6 +-- src/routes/measurement.spec.ts | 24 ++++++++++- src/routes/measurement.ts | 8 ++++ src/routes/sample.spec.ts | 74 +++++++++++++++++++++++++++++++++- src/routes/sample.ts | 32 +++++++++++++-- src/routes/template.spec.ts | 3 +- src/routes/validate/sample.ts | 11 +++++ src/test/db.json | 55 ++++++++++++++++++------- 10 files changed, 194 insertions(+), 29 deletions(-) diff --git a/api/sample.yaml b/api/sample.yaml index d074172..eae0ddc 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -170,7 +170,7 @@ /sample/new: post: summary: add sample - description: 'Auth: basic, levels: write, maintain, dev, admin' + description: 'Auth: basic, levels: write, maintain, dev, admin. Number property is only for admin when adding existing samples' x-doc: 'Adds status: 0 automatically' tags: - /sample @@ -181,7 +181,12 @@ content: application/json: schema: - $ref: 'api.yaml#/components/schemas/Sample' + allOf: + - $ref: 'api.yaml#/components/schemas/Sample' + properties: + number: + type: string + readOnly: false responses: 200: description: samples details diff --git a/api/schemas.yaml b/api/schemas.yaml index c4814b7..21ceddf 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -69,6 +69,7 @@ Sample: relation: type: string example: part to this sample + SampleDetail: allOf: - $ref: 'api.yaml#/components/schemas/_Id' diff --git a/src/index.ts b/src/index.ts index 7dda199..3776c34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,14 +7,12 @@ import db from './db'; // TODO: changelog // TODO: check executing index.js/move everything needed into dist -// TODO: One condition per sample // TODO: validation: VZ, Humidity: min/max value, DPT: filename -// TODO: condition values not needed on initial add -// TODO: add multiple samples at once -// TODO: coverage +// TODO: add multiple samples at once (only GUI) // TODO: think about the display of deleted/new samples and validation in data and UI // TODO: improve error coverage // TODO: guess properties from material name in UI +// TODO: mongodb user // tell if server is running in debug or production environment console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index 8bef49b..25cc5e9 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -586,14 +586,24 @@ describe('/measurement', () => { done(); }); }); + it('rejects no values', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000001', values: {}, measurement_template: '300000000000000000000002'}, + res: {status: 'At least one value is required'} + }); + }); it('rejects a value not in the value range', done => { TestHelper.request(server, done, { method: 'post', url: '/measurement/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {sample_id: '400000000000000000000001', values: {val1: 4}, measurement_template: '300000000000000000000003'}, - res: {status: 'Invalid body format', details: '"val1" must be one of [1, 2, 3, null]'} + req: {sample_id: '400000000000000000000001', values: {val2: 5}, measurement_template: '300000000000000000000004'}, + res: {status: 'Invalid body format', details: '"val2" must be one of [1, 2, 3, 4, null]'} }); }); it('rejects a value below minimum range', done => { @@ -664,6 +674,16 @@ describe('/measurement', () => { done(); }); }); + it('rejects an old version of a measurement template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000001', values: {val1: 2}, measurement_template: '300000000000000000000003'}, + res: {status: 'Old template version not allowed'} + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'post', diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index 2b94060..a1b3a94 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -130,6 +130,14 @@ async function templateCheck (measurement, param, res, next) { // validate meas // fill not given values for new measurements if (param === 'new') { + // get all template versions and check if given is latest + const templateVersions = await MeasurementTemplateModel.find({first_id: templateData.first_id}).sort({version: -1}).lean().exec().catch(err => next(err)) as any; + if (templateVersions instanceof Error) return false; + if (measurement.measurement_template !== templateVersions[0]._id.toString()) { // template not latest + res.status(400).json({status: 'Old template version not allowed'}); + return false; + } + if (Object.keys(measurement.values).length === 0) { res.status(400).json({status: 'At least one value is required'}); return false diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index f2fdd36..baa46fe 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -454,6 +454,16 @@ describe('/sample', () => { res: {status: 'Color not available for material'} }); }); + it('rejects an undefined color for the same material', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {type: 'part', color: 'signalviolet', batch: '114531', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Color not available for material'} + }); + }); it('rejects an unknown material id', done => { TestHelper.request(server, done, { method: 'put', @@ -573,6 +583,26 @@ describe('/sample', () => { res: {_id: '400000000000000000000006', number: 'Rng36', type: 'granulate', color: 'black', batch: '', condition: {}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} }); }); + it('rejects an old version of a condition template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition: {p1: 36, condition_template: '200000000000000000000004'}}, + res: {status: 'Old template version not allowed'} + }); + }); + it('allows keeping an old version of a condition template', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {condition: {p1: 36, condition_template: '200000000000000000000004'}}, + res: {_id: '400000000000000000000004', number: '32', type: 'granulate', color: 'black', batch: '1653000308', condition: {p1: 36, condition_template: '200000000000000000000004'}, material_id: '100000000000000000000005', note_id: '500000000000000000000003', user_id: '000000000000000000000003'} + }); + }); it('rejects an changing back to an empty condition', done => { TestHelper.request(server, done, { method: 'put', @@ -1108,7 +1138,7 @@ describe('/sample', () => { res: {status: 'Material not available'} }); }); - it('rejects a sample number', done => { + it('rejects a sample number for a write user', done => { TestHelper.request(server, done, { method: 'post', url: '/sample/new', @@ -1118,6 +1148,38 @@ describe('/sample', () => { res: {status: 'Invalid body format', details: '"number" is not allowed'} }); }); + it('allows a sample number for an admin user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {number: 'Rng34', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }).end((err, res) => { + if (err) return done (err); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('number', 'Rng34'); + should(res.body).have.property('color', 'black'); + should(res.body).have.property('type', 'granulate'); + should(res.body).have.property('batch', '1560237365'); + should(res.body).have.property('condition', {}); + should(res.body).have.property('material_id', '100000000000000000000001'); + should(res.body).have.property('note_id').be.type('string'); + should(res.body).have.property('user_id', '000000000000000000000003'); + done(); + }); + }); + it('rejects an existing sample number for an admin user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {number: 'Rng33', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Sample number already taken'} + }); + }); it('rejects an invalid sample reference', done => { TestHelper.request(server, done, { method: 'post', @@ -1208,6 +1270,16 @@ describe('/sample', () => { res: {status: 'Condition template not available'} }); }); + it('rejects an old version of a condition template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {p1: 36, condition_template: '200000000000000000000004'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Old template version not allowed'} + }); + }); it('rejects a missing color', done => { TestHelper.request(server, done, { method: 'post', diff --git a/src/routes/sample.ts b/src/routes/sample.ts index e741c4a..15d2a62 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -90,7 +90,7 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { } if (sample.hasOwnProperty('condition') && !(_.isEmpty(sample.condition) && _.isEmpty(sampleData.condition))) { // do not execute check if condition is and was empty - if (!await conditionCheck(sample.condition, 'change', res, next)) return; + if (!await conditionCheck(sample.condition, 'change', res, next, sampleData.condition.condition_template.toString() !== sample.condition.condition_template)) return; } if (sample.hasOwnProperty('notes')) { @@ -217,7 +217,7 @@ router.post('/sample/new', async (req, res, next) => { req.body.condition = {}; } - const {error, value: sample} = SampleValidate.input(req.body, 'new'); + const {error, value: sample} = SampleValidate.input(req.body, 'new' + (req.authDetails.level === 'admin' ? '-admin' : '')); if (error) return res400(error, res); if (!await materialCheck(sample, res, next)) return; @@ -232,7 +232,12 @@ router.post('/sample/new', async (req, res, next) => { } sample.status = globals.status.new; // set status to new - sample.number = await numberGenerate(sample, req, res, next); + if (sample.hasOwnProperty('number')) { + if (!await numberCheck(sample, res, next)) return; + } + else { + sample.number = await numberGenerate(sample, req, res, next); + } if (!sample.number) return; await new NoteModel(sample.notes).save((err, data) => { // save notes @@ -272,6 +277,15 @@ async function numberGenerate (sample, req, res, next) { // generate number in return req.authDetails.location + (sampleData ? Number(sampleData.number.replace(/[^0-9]+/g, '')) + 1 : 1); } +async function numberCheck(sample, res, next) { + const sampleData = await SampleModel.findOne({number: sample.number}).lean().exec().catch(err => {next(err); return false;}); + if (sampleData) { // found entry with sample number + res.status(400).json({status: 'Sample number already taken'}); + return false + } + return true; +} + async function materialCheck (sample, res, next, id = sample.material_id) { // validate material_id and color, returns false if invalid const materialData = await MaterialModel.findById(id).lean().exec().catch(err => next(err)) as any; if (materialData instanceof Error) return false; @@ -286,7 +300,7 @@ async function materialCheck (sample, res, next, id = sample.material_id) { // return true; } -async function conditionCheck (condition, param, res, next) { // validate treatment template, returns false if invalid, otherwise template data +async function conditionCheck (condition, param, res, next, checkVersion = true) { // validate treatment template, returns false if invalid, otherwise template data if (!condition.condition_template || !IdValidate.valid(condition.condition_template)) { // template id not found res.status(400).json({status: 'Condition template not available'}); return false; @@ -298,6 +312,16 @@ async function conditionCheck (condition, param, res, next) { // validate treat return false; } + if (checkVersion) { + // get all template versions and check if given is latest + const conditionVersions = await ConditionTemplateModel.find({first_id: conditionData.first_id}).sort({version: -1}).lean().exec().catch(err => next(err)) as any; + if (conditionVersions instanceof Error) return false; + if (condition.condition_template !== conditionVersions[0]._id.toString()) { // template not latest + res.status(400).json({status: 'Old template version not allowed'}); + return false; + } + } + // validate parameters const {error, value: ignore} = ParametersValidate.input(_.omit(condition, 'condition_template'), conditionData.parameters, param); if (error) {res400(error, res); return false;} diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index e69b480..cbc481a 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -4,7 +4,6 @@ import TemplateConditionModel from '../models/condition_template'; import TemplateMeasurementModel from '../models/measurement_template'; import TestHelper from "../test/helper"; -// TODO: do not allow usage of old templates for new samples describe('/template', () => { let server; @@ -644,7 +643,7 @@ describe('/template', () => { req: {parameters: [{name: 'weight %', range: {}}]} }).end((err, res) => { if (err) return done(err); - should(_.omit(res.body, '_id')).be.eql({name: 'kf', version: 3, parameters: [{name: 'weight %', range: {}}]}); + should(_.omit(res.body, '_id')).be.eql({name: 'kf', version: 2, parameters: [{name: 'weight %', range: {}}]}); done(); }); }); diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 93b86b1..9cb8cbb 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -67,6 +67,17 @@ export default class SampleValidate { notes: this.sample.notes, }).validate(data); } + else if (param === 'new-admin') { + return Joi.object({ + number: this.sample.number.required(), + color: this.sample.color.required(), + type: this.sample.type.required(), + batch: this.sample.batch.required(), + condition: this.sample.condition.required(), + material_id: IdValidate.get().required(), + notes: this.sample.notes.required() + }).validate(data); + } else { return{error: 'No parameter specified!', value: {}}; } diff --git a/src/test/db.json b/src/test/db.json index 4ef811c..ea2dd10 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -59,9 +59,8 @@ "color": "black", "batch": "1653000308", "condition": { - "material": "hot air", - "weeks": 5, - "condition_template": {"$oid":"200000000000000000000001"} + "p1": 44, + "condition_template": {"$oid":"200000000000000000000004"} }, "material_id": {"$oid":"100000000000000000000005"}, "note_id": {"$oid":"500000000000000000000003"}, @@ -447,24 +446,37 @@ "__v": 0 }, { - "_id": {"$oid":"200000000000000000000002"}, - "first_id": {"$oid":"200000000000000000000001"}, - "name": "heat treatment 2", - "version": 2, + "_id": {"$oid":"200000000000000000000003"}, + "first_id": {"$oid":"200000000000000000000003"}, + "name": "raw material", + "version": 1, + "parameters": [ + ], + "__v": 0 + }, + { + "_id": {"$oid":"200000000000000000000004"}, + "first_id": {"$oid":"200000000000000000000004"}, + "name": "old condition", + "version": 1, "parameters": [ { - "name": "material", + "name": "p1", "range": {} } ], "__v": 0 }, { - "_id": {"$oid":"200000000000000000000003"}, - "first_id": {"$oid":"200000000000000000000003"}, - "name": "raw material", - "version": 1, + "_id": {"$oid":"200000000000000000000005"}, + "first_id": {"$oid":"200000000000000000000004"}, + "name": "new condition", + "version": 2, "parameters": [ + { + "name": "p11", + "range": {} + } ], "__v": 0 } @@ -487,9 +499,9 @@ }, { "_id": {"$oid":"300000000000000000000002"}, - "first_id": {"$oid":"300000000000000000000001"}, + "first_id": {"$oid":"300000000000000000000002"}, "name": "kf", - "version": 2, + "version": 1, "parameters": [ { "name": "weight %", @@ -522,6 +534,21 @@ } ], "__v": 0 + }, + { + "_id": {"$oid":"300000000000000000000004"}, + "first_id": {"$oid":"300000000000000000000003"}, + "name": "mt 31", + "version": 2, + "parameters": [ + { + "name": "val2", + "range": { + "values": [1,2,3,4] + } + } + ], + "__v": 0 } ], "users": [ From ca29cef48c52c79b615e191ac81480191d3d7c8d Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 5 Jun 2020 08:50:06 +0200 Subject: [PATCH 63/83] implemented changelog --- .idea/dictionaries/VLE2FE.xml | 1 + api/api.yaml | 2 +- api/others.yaml | 43 ----------- api/root.yaml | 102 +++++++++++++++++++++++++ src/db.ts | 25 ++++++- src/index.ts | 1 - src/models/changelog.ts | 11 +++ src/models/condition_template.ts | 13 +++- src/models/material.ts | 15 +++- src/models/material_groups.ts | 9 ++- src/models/material_suppliers.ts | 9 ++- src/models/measurement.ts | 9 ++- src/models/measurement_template.ts | 13 +++- src/models/note.ts | 9 ++- src/models/note_field.ts | 9 ++- src/models/sample.ts | 9 ++- src/models/user.ts | 9 ++- src/routes/material.spec.ts | 89 ++++++++++++++++++++-- src/routes/material.ts | 24 +++--- src/routes/measurement.spec.ts | 78 ++++++++++++++++++- src/routes/measurement.ts | 8 +- src/routes/root.spec.ts | 116 +++++++++++++++++++++++++++++ src/routes/root.ts | 19 +++++ src/routes/sample.spec.ts | 84 ++++++++++++++++++++- src/routes/sample.ts | 35 +++++---- src/routes/template.spec.ts | 62 ++++++++++++++- src/routes/template.ts | 3 + src/routes/user.spec.ts | 50 +++++++++++++ src/routes/user.ts | 8 +- src/routes/validate/root.ts | 50 +++++++++++++ src/routes/validate/sample.ts | 2 +- src/test/db.json | 58 +++++++++++++++ src/test/helper.ts | 42 +++++++++-- 33 files changed, 905 insertions(+), 112 deletions(-) delete mode 100644 api/others.yaml create mode 100644 api/root.yaml create mode 100644 src/models/changelog.ts create mode 100644 src/routes/validate/root.ts diff --git a/.idea/dictionaries/VLE2FE.xml b/.idea/dictionaries/VLE2FE.xml index 1dd7309..5337928 100644 --- a/.idea/dictionaries/VLE2FE.xml +++ b/.idea/dictionaries/VLE2FE.xml @@ -5,6 +5,7 @@ cfenv dfopdb janedoe + pagesize testcomment diff --git a/api/api.yaml b/api/api.yaml index 9090378..d281206 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -62,7 +62,7 @@ tags: paths: allOf: - - $ref: 'others.yaml' + - $ref: 'root.yaml' - $ref: 'sample.yaml' - $ref: 'material.yaml' - $ref: 'measurement.yaml' diff --git a/api/others.yaml b/api/others.yaml deleted file mode 100644 index a953bf8..0000000 --- a/api/others.yaml +++ /dev/null @@ -1,43 +0,0 @@ -/: - get: - summary: Root method - description: 'Auth: none' - tags: - - / - security: [] - responses: - 200: - description: Server is working - content: - application/json: - schema: - properties: - status: - type: string - example: 'API server up and running!' - 500: - $ref: 'api.yaml#/components/responses/500' - -/authorized: - get: - summary: Checks authorization - description: 'Auth: all, levels: read, write, maintain, dev, admin' - tags: - - / - responses: - 200: - description: Authorized - content: - application/json: - schema: - properties: - status: - type: string - example: 'Authorization successful' - method: - type: string - example: 'basic' - 401: - $ref: 'api.yaml#/components/responses/401' - 500: - $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/api/root.yaml b/api/root.yaml new file mode 100644 index 0000000..3070412 --- /dev/null +++ b/api/root.yaml @@ -0,0 +1,102 @@ +/: + get: + summary: Root method + description: 'Auth: none' + tags: + - / + security: [] + responses: + 200: + description: Server is working + content: + application/json: + schema: + properties: + status: + type: string + example: 'API server up and running!' + 500: + $ref: 'api.yaml#/components/responses/500' + +/authorized: + get: + summary: Checks authorization + description: 'Auth: all, levels: read, write, maintain, dev, admin' + tags: + - / + responses: + 200: + description: Authorized + content: + application/json: + schema: + properties: + status: + type: string + example: 'Authorization successful' + method: + type: string + example: 'basic' + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' + +/changelog/{timestamp}/{page}/{pagesize}: + parameters: + - name: timestamp + in: path + required: true + schema: + type: string + example: 1970-01-01T00:00:00.000Z + - name: page + in: path + required: true + schema: + type: number + example: 3 + - name: pagesize + in: path + required: true + schema: + type: number + example: 30 + get: + summary: get changelog + description: 'Auth: basic, levels: maintain, admin
    Displays all logs older than timestamp, sorted by date descending, page defaults to 0, pagesize defaults to 25
    Avoid using high page numbers for older logs, better use an older timestamp' + tags: + - / + responses: + 200: + description: Changelog + content: + application/json: + schema: + properties: + date: + type: string + example: 1970-01-01T00:00:00.000Z + action: + type: string + example: PUT /sample/400000000000000000000001 + collection: + type: string + example: samples + conditions: + type: object + example: + _id: '400000000000000000000001' + data: + type: object + example: + type: part + status: 0 + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/src/db.ts b/src/db.ts index fb5d424..60dadf9 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,5 +1,7 @@ import mongoose from 'mongoose'; import cfenv from 'cfenv'; +import _ from 'lodash'; +import ChangelogModel from './models/changelog'; // mongoose.set('debug', true); // enable mongoose debug @@ -112,6 +114,27 @@ export default class db { }); } + // changelog entry + static log(req, thisOrCollection, conditions = null, data = null) { // expects (req, this (from query helper)) or (req, collection, conditions, data) + if (! (conditions || data)) { // (req, this) + data = thisOrCollection._update ? _.cloneDeep(thisOrCollection._update) : {}; // replace undefined with {} + Object.keys(data).forEach(key => { + if (key[0] === '$') { + data[key.substr(1)] = data[key]; + delete data[key]; + } + }); + new ChangelogModel({action: req.method + ' ' + req.url, collectionName: thisOrCollection._collection.collectionName, conditions: thisOrCollection._conditions, data: data, user_id: req.authDetails.id ? req.authDetails.id : null}).save(err => { + if (err) console.error(err); + }); + } + else { // (req, collection, conditions, data) + new ChangelogModel({action: req.method + ' ' + req.url, collectionName: thisOrCollection, conditions: conditions, data: data, user_id: req.authDetails.id ? req.authDetails.id : null}).save(err => { + if (err) console.error(err); + }); + } + } + private static oidResolve (object: any) { // resolve $oid fields to actual ObjectIds recursively Object.keys(object).forEach(key => { if (object[key] !== null && object[key].hasOwnProperty('$oid')) { // found oid, replace @@ -123,4 +146,4 @@ export default class db { }); return object; } -}; \ No newline at end of file +}; diff --git a/src/index.ts b/src/index.ts index 3776c34..4cf4f45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,6 @@ import mongoSanitize from 'mongo-sanitize'; import api from './api'; import db from './db'; -// TODO: changelog // TODO: check executing index.js/move everything needed into dist // TODO: validation: VZ, Humidity: min/max value, DPT: filename // TODO: add multiple samples at once (only GUI) diff --git a/src/models/changelog.ts b/src/models/changelog.ts new file mode 100644 index 0000000..75600c4 --- /dev/null +++ b/src/models/changelog.ts @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; + +const ChangelogSchema = new mongoose.Schema({ + action: String, + collectionName: String, + conditions: Object, + data: Object, + user_id: mongoose.Schema.Types.ObjectId +}, {minimize: false}); + +export default mongoose.model>('changelog', ChangelogSchema); \ No newline at end of file diff --git a/src/models/condition_template.ts b/src/models/condition_template.ts index 62bf621..ca61da2 100644 --- a/src/models/condition_template.ts +++ b/src/models/condition_template.ts @@ -1,13 +1,20 @@ import mongoose from 'mongoose'; +import db from '../db'; const ConditionTemplateSchema = new mongoose.Schema({ first_id: mongoose.Schema.Types.ObjectId, name: String, version: Number, - parameters: [{ + parameters: [new mongoose.Schema({ name: String, range: mongoose.Schema.Types.Mixed - }] + } ,{ _id : false })] }, {minimize: false}); // to allow empty objects -export default mongoose.model('condition_template', ConditionTemplateSchema); \ No newline at end of file +// changelog query helper +ConditionTemplateSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('condition_template', ConditionTemplateSchema); \ No newline at end of file diff --git a/src/models/material.ts b/src/models/material.ts index a183020..bcebb83 100644 --- a/src/models/material.ts +++ b/src/models/material.ts @@ -1,14 +1,15 @@ import mongoose from 'mongoose'; import MaterialSupplierModel from '../models/material_suppliers'; import MaterialGroupsModel from '../models/material_groups'; +import db from '../db'; const MaterialSchema = new mongoose.Schema({ name: {type: String, index: {unique: true}}, supplier_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialSupplierModel}, group_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialGroupsModel}, - mineral: String, - glass_fiber: String, - carbon_fiber: String, + mineral: Number, + glass_fiber: Number, + carbon_fiber: Number, numbers: [{ color: String, number: String @@ -16,4 +17,10 @@ const MaterialSchema = new mongoose.Schema({ status: Number }, {minimize: false}); -export default mongoose.model('material', MaterialSchema); \ No newline at end of file +// changelog query helper +MaterialSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('material', MaterialSchema); \ No newline at end of file diff --git a/src/models/material_groups.ts b/src/models/material_groups.ts index e9c9861..00be706 100644 --- a/src/models/material_groups.ts +++ b/src/models/material_groups.ts @@ -1,7 +1,14 @@ import mongoose from 'mongoose'; +import db from '../db'; const MaterialGroupsSchema = new mongoose.Schema({ name: {type: String, index: {unique: true}} }); -export default mongoose.model('material_groups', MaterialGroupsSchema); \ No newline at end of file +// changelog query helper +MaterialGroupsSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('material_groups', MaterialGroupsSchema); \ No newline at end of file diff --git a/src/models/material_suppliers.ts b/src/models/material_suppliers.ts index 573d397..5c47e3b 100644 --- a/src/models/material_suppliers.ts +++ b/src/models/material_suppliers.ts @@ -1,7 +1,14 @@ import mongoose from 'mongoose'; +import db from '../db'; const MaterialSuppliersSchema = new mongoose.Schema({ name: {type: String, index: {unique: true}} }); -export default mongoose.model('material_suppliers', MaterialSuppliersSchema); \ No newline at end of file +// changelog query helper +MaterialSuppliersSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('material_suppliers', MaterialSuppliersSchema); \ No newline at end of file diff --git a/src/models/measurement.ts b/src/models/measurement.ts index d003ea5..1136e6b 100644 --- a/src/models/measurement.ts +++ b/src/models/measurement.ts @@ -1,6 +1,7 @@ import mongoose from 'mongoose'; import SampleModel from './sample'; import MeasurementTemplateModel from './measurement_template'; +import db from '../db'; @@ -11,4 +12,10 @@ const MeasurementSchema = new mongoose.Schema({ status: Number }, {minimize: false}); -export default mongoose.model('measurement', MeasurementSchema); \ No newline at end of file +// changelog query helper +MeasurementSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('measurement', MeasurementSchema); \ No newline at end of file diff --git a/src/models/measurement_template.ts b/src/models/measurement_template.ts index af14fee..b34e847 100644 --- a/src/models/measurement_template.ts +++ b/src/models/measurement_template.ts @@ -1,13 +1,20 @@ import mongoose from 'mongoose'; +import db from '../db'; const MeasurementTemplateSchema = new mongoose.Schema({ first_id: mongoose.Schema.Types.ObjectId, name: String, version: Number, - parameters: [{ + parameters: [new mongoose.Schema({ name: String, range: mongoose.Schema.Types.Mixed - }] + } ,{ _id : false })] }, {minimize: false}); // to allow empty objects -export default mongoose.model('measurement_template', MeasurementTemplateSchema); \ No newline at end of file +// changelog query helper +MeasurementTemplateSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('measurement_template', MeasurementTemplateSchema); \ No newline at end of file diff --git a/src/models/note.ts b/src/models/note.ts index cd0847b..5d02502 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -1,4 +1,5 @@ import mongoose from 'mongoose'; +import db from '../db'; const NoteSchema = new mongoose.Schema({ comment: String, @@ -9,4 +10,10 @@ const NoteSchema = new mongoose.Schema({ custom_fields: mongoose.Schema.Types.Mixed }); -export default mongoose.model('note', NoteSchema); \ No newline at end of file +// changelog query helper +NoteSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('note', NoteSchema); \ No newline at end of file diff --git a/src/models/note_field.ts b/src/models/note_field.ts index 86158e3..733ba02 100644 --- a/src/models/note_field.ts +++ b/src/models/note_field.ts @@ -1,8 +1,15 @@ import mongoose from 'mongoose'; +import db from '../db'; const NoteFieldSchema = new mongoose.Schema({ name: {type: String, index: {unique: true}}, qty: Number }); -export default mongoose.model('note_field', NoteFieldSchema); \ No newline at end of file +// changelog query helper +NoteFieldSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('note_field', NoteFieldSchema); \ No newline at end of file diff --git a/src/models/sample.ts b/src/models/sample.ts index 1338728..0e457d8 100644 --- a/src/models/sample.ts +++ b/src/models/sample.ts @@ -3,6 +3,7 @@ import mongoose from 'mongoose'; import MaterialModel from './material'; import NoteModel from './note'; import UserModel from './user'; +import db from '../db'; const SampleSchema = new mongoose.Schema({ number: {type: String, index: {unique: true}}, @@ -16,4 +17,10 @@ const SampleSchema = new mongoose.Schema({ status: Number }, {minimize: false}); -export default mongoose.model('sample', SampleSchema); \ No newline at end of file +// changelog query helper +SampleSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('sample', SampleSchema); \ No newline at end of file diff --git a/src/models/user.ts b/src/models/user.ts index 50178a6..1e50d0c 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -1,4 +1,5 @@ import mongoose from 'mongoose'; +import db from '../db'; const UserSchema = new mongoose.Schema({ name: {type: String, index: {unique: true}}, @@ -10,4 +11,10 @@ const UserSchema = new mongoose.Schema({ device_name: String }); -export default mongoose.model('user', UserSchema); \ No newline at end of file +// changelog query helper +UserSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('user', UserSchema); \ No newline at end of file diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 9645d1b..e91e87e 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -307,7 +307,6 @@ describe('/material', () => { auth: {basic: 'janedoe'}, httpStatus: 200, req: {name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]} - , }).end((err, res) => { if (err) return done(err); should(res.body).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]}); @@ -317,7 +316,7 @@ describe('/material', () => { data.group_id = data.group_id.toString(); data.supplier_id = data.supplier_id.toString(); data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); - should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier_id: '110000000000000000000002', group_id: '900000000000000000000002', mineral: '0', glass_fiber: '35', carbon_fiber: '0', numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: 0, __v: 0}); + should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier_id: '110000000000000000000002', group_id: '900000000000000000000002', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: 0, __v: 0}); MaterialGroupModel.find({name: 'PA6/6T'}).lean().exec((err, data) => { if (err) return done(err); should(data).have.lengthOf(1); @@ -332,6 +331,24 @@ describe('/material', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]}, + log: { + collection: 'materials', + dataAdd: { + group_id: '900000000000000000000002', + supplier_id: '110000000000000000000002', + status: 0 + }, + dataIgn: ['supplier', 'group'] + } + }); + }); it('accepts a color without number', done => { TestHelper.request(server, done, { method: 'put', @@ -469,6 +486,18 @@ describe('/material', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200, + log: { + collection: 'materials', + dataAdd: { status: -1} + } + }); + }); it('rejects deleting a material referenced by samples', done => { TestHelper.request(server, done, { method: 'delete', @@ -537,6 +566,21 @@ describe('/material', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'materials', + dataAdd: { + status: 0 + } + } + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', @@ -592,6 +636,21 @@ describe('/material', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/validate/100000000000000000000007', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'materials', + dataAdd: { + status: 10 + } + } + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', @@ -669,9 +728,9 @@ describe('/material', () => { should(materialData).have.lengthOf(1); should(materialData[0]).have.only.keys('_id', 'name', 'supplier_id', 'group_id', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v'); should(materialData[0]).have.property('name', 'Crastin CE 2510'); - should(materialData[0]).have.property('mineral', '0'); - should(materialData[0]).have.property('glass_fiber', '30'); - should(materialData[0]).have.property('carbon_fiber', '0'); + should(materialData[0]).have.property('mineral', 0); + should(materialData[0]).have.property('glass_fiber', 30); + should(materialData[0]).have.property('carbon_fiber', 0); should(materialData[0]).have.property('status',globals.status.new); should(materialData[0].numbers).have.lengthOf(0); MaterialGroupModel.findById(materialData[0].group_id).lean().exec((err, data) => { @@ -686,6 +745,20 @@ describe('/material', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []}, + log: { + collection: 'materials', + dataAdd: {status: 0}, + dataIgn: ['group_id', 'supplier_id', 'group', 'supplier'] + } + }); + }); it('accepts a color without number', done => { TestHelper.request(server, done, { method: 'post', @@ -714,9 +787,9 @@ describe('/material', () => { should(data[0]).have.only.keys('_id', 'name', 'supplier_id', 'group_id', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v'); should(data[0]).have.property('_id'); should(data[0]).have.property('name', 'Crastin CE 2510'); - should(data[0]).have.property('mineral', '0'); - should(data[0]).have.property('glass_fiber', '30'); - should(data[0]).have.property('carbon_fiber', '0'); + should(data[0]).have.property('mineral', 0); + should(data[0]).have.property('glass_fiber', 30); + should(data[0]).have.property('carbon_fiber', 0); should(data[0]).have.property('status',globals.status.new); should(_.omit(data[0].numbers[0], '_id')).be.eql({color: 'black', number: ''}); done(); diff --git a/src/routes/material.ts b/src/routes/material.ts index 4be1137..8373c9d 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -10,6 +10,7 @@ import IdValidate from './validate/id'; import res400 from './validate/res400'; import mongoose from 'mongoose'; import globals from '../globals'; +import db from '../db'; @@ -67,11 +68,11 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { if (!await nameCheck(material, res, next)) return; } if (material.hasOwnProperty('group')) { - material = await groupResolve(material, next); + material = await groupResolve(material, req, next); if (!material) return; } if (material.hasOwnProperty('supplier')) { - material = await supplierResolve(material, next); + material = await supplierResolve(material, req, next); if (!material) return; } @@ -80,7 +81,7 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { material.status = globals.status.new; // set status to new } - await MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).populate('group_id').populate('supplier_id').lean().exec((err, data) => { + await MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).log(req).populate('group_id').populate('supplier_id').lean().exec((err, data) => { if (err) return next(err); res.json(MaterialValidate.output(data)); }); @@ -96,7 +97,7 @@ router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { if (data.length) { return res.status(400).json({status: 'Material still in use'}); } - MaterialModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).populate('group_id').populate('supplier_id').lean().exec((err, data) => { + MaterialModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).log(req).populate('group_id').populate('supplier_id').lean().exec((err, data) => { if (err) return next(err); if (data) { res.json({status: 'OK'}); @@ -127,15 +128,16 @@ router.post('/material/new', async (req, res, next) => { if (error) return res400(error, res); if (!await nameCheck(material, res, next)) return; - material = await groupResolve(material, next); + material = await groupResolve(material, req, next); if (!material) return; - material = await supplierResolve(material, next); + material = await supplierResolve(material, req, next); if (!material) return; material.status = globals.status.new; // set status to new await new MaterialModel(material).save(async (err, data) => { if (err) return next(err); + db.log(req, 'materials', {_id: data._id}, data.toObject()); await data.populate('group_id').populate('supplier_id').execPopulate().catch(err => next(err)); if (data instanceof Error) return; res.json(MaterialValidate.output(data.toObject())); @@ -176,16 +178,16 @@ async function nameCheck (material, res, next) { // check if name was already t return true; } -async function groupResolve (material, next) { - const groupData = await MaterialGroupModel.findOneAndUpdate({name: material.group}, {name: material.group}, {upsert: true, new: true}).lean().exec().catch(err => next(err)) as any; +async function groupResolve (material, req, next) { + const groupData = await MaterialGroupModel.findOneAndUpdate({name: material.group}, {name: material.group}, {upsert: true, new: true}).log(req).lean().exec().catch(err => next(err)) as any; if (groupData instanceof Error) return false; material.group_id = groupData._id; delete material.group; return material; } -async function supplierResolve (material, next) { - const supplierData = await MaterialSupplierModel.findOneAndUpdate({name: material.supplier}, {name: material.supplier}, {upsert: true, new: true}).lean().exec().catch(err => next(err)) as any; +async function supplierResolve (material, req, next) { + const supplierData = await MaterialSupplierModel.findOneAndUpdate({name: material.supplier}, {name: material.supplier}, {upsert: true, new: true}).log(req).lean().exec().catch(err => next(err)) as any; if (supplierData instanceof Error) return false; material.supplier_id = supplierData._id; delete material.supplier; @@ -193,7 +195,7 @@ async function supplierResolve (material, next) { } function setStatus (status, req, res, next) { // set measurement status - MaterialModel.findByIdAndUpdate(req.params.id, {status: status}).lean().exec((err, data) => { + MaterialModel.findByIdAndUpdate(req.params.id, {status: status}).log(req).lean().exec((err, data) => { if (err) return next(err); if (!data) { diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index 25cc5e9..dd43520 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -138,6 +138,23 @@ describe('/measurement', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {values: {dpt: [[1,2],[3,4],[5,6]]}}, + log: { + collection: 'measurements', + dataAdd: { + measurement_template: '300000000000000000000001', + sample_id: '400000000000000000000001', + status: 0 + } + } + }); + }); it('allows changing only one value', done => { TestHelper.request(server, done, { method: 'put', @@ -296,7 +313,7 @@ describe('/measurement', () => { method: 'delete', url: '/measurement/800000000000000000000001', auth: {basic: 'janedoe'}, - httpStatus: 200, + httpStatus: 200 }).end((err, res) => { if (err) return done(err); should(res.body).be.eql({status: 'OK'}); @@ -307,6 +324,20 @@ describe('/measurement', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + log: { + collection: 'measurements', + dataAdd: { + status: -1 + } + } + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'delete', @@ -383,6 +414,21 @@ describe('/measurement', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'measurements', + dataAdd: { + status: 0 + } + } + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', @@ -438,6 +484,21 @@ describe('/measurement', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/validate/800000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'measurements', + dataAdd: { + status: 10 + } + } + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', @@ -517,6 +578,21 @@ describe('/measurement', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + log: { + collection: 'measurements', + dataAdd: { + status: 0 + } + } + }); + }); it('rejects an invalid sample id', done => { TestHelper.request(server, done, { method: 'post', diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index a1b3a94..47af305 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -9,6 +9,7 @@ import IdValidate from './validate/id'; import res400 from './validate/res400'; import ParametersValidate from './validate/parameters'; import globals from '../globals'; +import db from '../db'; const router = express.Router(); @@ -56,7 +57,7 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { } if (!await templateCheck(measurement, 'change', res, next)) return; - await MeasurementModel.findByIdAndUpdate(req.params.id, measurement, {new: true}).lean().exec((err, data) => { + await MeasurementModel.findByIdAndUpdate(req.params.id, measurement, {new: true}).log(req).lean().exec((err, data) => { if (err) return next(err); res.json(MeasurementValidate.output(data)); }); @@ -71,7 +72,7 @@ router.delete('/measurement/' + IdValidate.parameter(), (req, res, next) => { return res.status(404).json({status: 'Not found'}); } if (!await sampleIdCheck(data, req, res, next)) return; - await MeasurementModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).lean().exec(err => { + await MeasurementModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).log(req).lean().exec(err => { if (err) return next(err); return res.json({status: 'OK'}); }); @@ -103,6 +104,7 @@ router.post('/measurement/new', async (req, res, next) => { measurement.status = 0; await new MeasurementModel(measurement).save((err, data) => { if (err) return next(err); + db.log(req, 'measurements', {_id: data._id}, data.toObject()); res.json(MeasurementValidate.output(data.toObject())); }); }); @@ -156,7 +158,7 @@ async function templateCheck (measurement, param, res, next) { // validate meas } function setStatus (status, req, res, next) { // set measurement status - MeasurementModel.findByIdAndUpdate(req.params.id, {status: status}).lean().exec((err, data) => { + MeasurementModel.findByIdAndUpdate(req.params.id, {status: status}).log(req).lean().exec((err, data) => { if (err) return next(err); if (!data) { diff --git a/src/routes/root.spec.ts b/src/routes/root.spec.ts index 569af8b..68531a5 100644 --- a/src/routes/root.spec.ts +++ b/src/routes/root.spec.ts @@ -1,4 +1,5 @@ import TestHelper from "../test/helper"; +import should from 'should/as-function'; import db from '../db'; @@ -20,6 +21,121 @@ describe('/', () => { }); }); + describe('GET /changelog/{timestamp}/{page}/{pagesize}', () => { + it('returns the first page', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/0/2', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(2); + should(res.body[0].date).be.eql('1979-07-28T06:04:51.000Z'); + should(res.body[1].date).be.eql('1979-07-28T06:04:50.000Z'); + should(res.body).matchEach(log => { + should(log).have.only.keys('date', 'action', 'collection', 'conditions', 'data'); + should(log).have.property('action', 'PUT /sample/400000000000000000000001'); + should(log).have.property('collection', 'samples'); + should(log).have.property('conditions', {_id: '400000000000000000000001'}); + should(log).have.property('data', {type: 'part', status: 0}); + }); + done(); + }); + }); + it('returns another page', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/1/2', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(1); + should(res.body[0].date).be.eql('1979-07-28T06:04:49.000Z'); + should(res.body).matchEach(log => { + should(log).have.only.keys('date', 'action', 'collection', 'conditions', 'data'); + should(log).have.property('action', 'PUT /sample/400000000000000000000001'); + should(log).have.property('collection', 'samples'); + should(log).have.property('conditions', {_id: '400000000000000000000001'}); + should(log).have.property('data', {type: 'part', status: 0}); + done(); + }); + }); + }); + it('returns an empty array for a page with no results', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/2', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(0); + done(); + }); + }); + it('rejects timestamps pre unix epoch', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1879-07-28T06:04:51.000Z/10/2', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"timestamp" must be larger than or equal to "1970-01-01T00:00:00.000Z"'} + }); + }); + it('rejects invalid timestamps', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-14-28T06:04:51.000Z/10/2', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"timestamp" must be in ISO 8601 date format'} + }); + }); + it('rejects negative page numbers', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/-10/2', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"page" must be larger than or equal to 0'} + }); + }); + it('rejects negative pagesizes', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/-2', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"pagesize" must be larger than or equal to 0'} + }); + }); + it('rejects request from a write user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/2', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects requests from an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/2', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/2', + httpStatus: 401 + }); + }); + }); + describe('Unknown routes', () => { it('return a 404 message', done => { TestHelper.request(server, done, { diff --git a/src/routes/root.ts b/src/routes/root.ts index 2705280..946948f 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -1,5 +1,10 @@ import express from 'express'; import globals from '../globals'; +import RootValidate from './validate/root'; +import res400 from './validate/res400'; +import ChangelogModel from '../models/changelog'; +import mongoose from 'mongoose'; +import _ from 'lodash'; const router = express.Router(); @@ -12,4 +17,18 @@ router.get('/authorized', (req, res) => { res.json({status: 'Authorization successful', method: req.authDetails.method}); }); +router.get('/changelog/:timestamp/:page?/:pagesize?', (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + const {error, value: options} = RootValidate.changelogParams({timestamp: req.params.timestamp, page: req.params.page, pagesize: req.params.pagesize}); + if (error) return res400(error, res); + + const id = new mongoose.Types.ObjectId(Math.floor(new Date(options.timestamp).getTime() / 1000).toString(16) + '0000000000000000'); + ChangelogModel.find({_id: {$lte: id}}).sort({_id: -1}).skip(options.page * options.pagesize).limit(options.pagesize).lean().exec((err, data) => { + if (err) return next(err); + + res.json(_.compact(data.map(e => RootValidate.changelogOutput(e)))); // validate all and filter null values from validation errors + }); +}); + module.exports = router; diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index baa46fe..9ce2a88 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -13,7 +13,6 @@ import mongoose from 'mongoose'; // TODO: write script for data import // TODO: allow adding sample numbers for existing samples - describe('/sample', () => { let server; before(done => TestHelper.before(done)); @@ -374,6 +373,22 @@ describe('/sample', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {type: 'part', color: 'signalviolet', batch: '114531', condition: {condition_template: '200000000000000000000003'}, material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + log: { + collection: 'samples', + dataAdd: { + status: 0 + }, + dataIgn: ['notes', 'note_id'] + } + }); + }); it('adjusts the note_fields correctly', done => { TestHelper.request(server, done, { method: 'put', @@ -707,6 +722,19 @@ describe('/sample', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + log: { + collection: 'samples', + skip: 1, + dataAdd: {status: -1} + } + }); + }); it('keeps the notes of the sample', done => { TestHelper.request(server, done, { method: 'delete', @@ -867,6 +895,24 @@ describe('/sample', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'samples', + dataAdd: { + group_id: '900000000000000000000002', + supplier_id: '110000000000000000000002', + status: 0 + }, + dataIgn: ['group_id', 'supplier_id'] + } + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', @@ -922,6 +968,24 @@ describe('/sample', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'samples', + dataAdd: { + group_id: '900000000000000000000002', + supplier_id: '110000000000000000000002', + status: 10 + }, + dataIgn: ['group_id', 'supplier_id'] + } + }); + }); it('rejects validating a sample without condition', done => { TestHelper.request(server, done, { method: 'put', @@ -1038,6 +1102,24 @@ describe('/sample', () => { }) }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + log: { + collection: 'samples', + dataAdd: { + number: 'Rng37', + user_id: '000000000000000000000002', + status: 0 + }, + dataIgn: ['notes', 'note_id'] + } + }); + }); it('stores the custom fields', done => { TestHelper.request(server, done, { method: 'post', diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 15d2a62..3966c9b 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -14,6 +14,7 @@ import mongoose from 'mongoose'; import ConditionTemplateModel from '../models/condition_template'; import ParametersValidate from './validate/parameters'; import globals from '../globals'; +import db from '../db'; const router = express.Router(); @@ -101,9 +102,9 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { newNotes = !_.isEqual(_.pick(IdValidate.stringify(data), _.keys(sample.notes)), sample.notes); // check if notes were changed if (newNotes) { if (data.hasOwnProperty('custom_fields')) { // update note_fields - customFieldsChange(Object.keys(data.custom_fields), -1); + customFieldsChange(Object.keys(data.custom_fields), -1, req); } - await NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec(err => { // delete old notes + await NoteModel.findByIdAndDelete(sampleData.note_id).log(req).lean().exec(err => { // delete old notes if (err) return console.error(err); }); } @@ -112,9 +113,10 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (_.keys(sample.notes).length > 0 && newNotes) { // save new notes if (!await sampleRefCheck(sample, res, next)) return; if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields - customFieldsChange(Object.keys(sample.notes.custom_fields), 1); + customFieldsChange(Object.keys(sample.notes.custom_fields), 1, req); } let data = await new NoteModel(sample.notes).save().catch(err => { return next(err)}); // save new notes + db.log(req, 'notes', {_id: data._id}, data.toObject()); delete sample.notes; sample.note_id = data._id; } @@ -125,7 +127,7 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { sample.status = globals.status.new; } - await SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).lean().exec((err, data: any) => { + await SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).log(req).lean().exec((err, data: any) => { if (err) return next(err); res.json(SampleValidate.output(data)); }); @@ -145,18 +147,18 @@ router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { // only maintain and admin are allowed to edit other user's data if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; - await SampleModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).lean().exec(err => { // set sample status + await SampleModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).log(req).lean().exec(err => { // set sample status if (err) return next(err); // set status of associated measurements also to deleted - MeasurementModel.updateMany({sample_id: mongoose.Types.ObjectId(req.params.id)}, {status: -1}).lean().exec(err => { + MeasurementModel.updateMany({sample_id: mongoose.Types.ObjectId(req.params.id)}, {status: -1}).log(req).lean().exec(err => { if (err) return next(err); if (sampleData.note_id !== null) { // handle notes NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { // find notes to update note_fields if (err) return next(err); if (data.hasOwnProperty('custom_fields')) { // update note_fields - customFieldsChange(Object.keys(data.custom_fields), -1); + customFieldsChange(Object.keys(data.custom_fields), -1, req); } res.json({status: 'OK'}); }); @@ -172,7 +174,7 @@ router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { router.put('/sample/restore/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; - SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).lean().exec((err, data) => { + SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).log(req).lean().exec((err, data) => { if (err) return next(err); if (!data) { @@ -202,7 +204,7 @@ router.put('/sample/validate/' + IdValidate.parameter(), (req, res, next) => { return res.status(400).json({status: 'Sample without measurements cannot be valid'}); } - SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.validated}).lean().exec(err => { + SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.validated}).log(req).lean().exec(err => { if (err) return next(err); res.json({status: 'OK'}); }); @@ -224,7 +226,7 @@ router.post('/sample/new', async (req, res, next) => { if (!await sampleRefCheck(sample, res, next)) return; if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields - customFieldsChange(Object.keys(sample.notes.custom_fields), 1); + customFieldsChange(Object.keys(sample.notes.custom_fields), 1, req); } if (!_.isEmpty(sample.condition)) { // do not execute check if condition is empty @@ -242,12 +244,14 @@ router.post('/sample/new', async (req, res, next) => { await new NoteModel(sample.notes).save((err, data) => { // save notes if (err) return next(err); + db.log(req, 'notes', {_id: data._id}, data.toObject()); delete sample.notes; sample.note_id = data._id; sample.user_id = req.authDetails.id; new SampleModel(sample).save((err, data) => { if (err) return next(err); + db.log(req, 'samples', {_id: data._id}, data.toObject()); res.json(SampleValidate.output(data.toObject())); }); }); @@ -330,7 +334,7 @@ async function conditionCheck (condition, param, res, next, checkVersion = true) function sampleRefCheck (sample, res, next) { // validate sample_references, resolves false for invalid reference return new Promise(resolve => { - if (sample.notes.sample_references.length > 0) { // there are sample_references + if (sample.notes.hasOwnProperty('sample_references') && sample.notes.sample_references.length > 0) { // there are sample_references let referencesCount = sample.notes.sample_references.length; // count to keep track of running async operations sample.notes.sample_references.forEach(reference => { @@ -353,17 +357,18 @@ function sampleRefCheck (sample, res, next) { // validate sample_references, re }); } -function customFieldsChange (fields, amount) { // update custom_fields and respective quantities +function customFieldsChange (fields, amount, req) { // update custom_fields and respective quantities fields.forEach(field => { - NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: amount}}, {new: true}).lean().exec((err, data: any) => { // check if field exists + NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: amount}} as any, {new: true}).log(req).lean().exec((err, data: any) => { // check if field exists if (err) return console.error(err); if (!data) { // new field - new NoteFieldModel({name: field, qty: 1}).save(err => { + new NoteFieldModel({name: field, qty: 1}).save((err, data) => { if (err) return console.error(err); + db.log(req, 'note_fields', {_id: data._id}, data.toObject()); }) } else if (data.qty <= 0) { // delete document if field is not used anymore - NoteFieldModel.findOneAndDelete({name: field}).lean().exec(err => { + NoteFieldModel.findOneAndDelete({name: field}).log(req).lean().exec(err => { if (err) return console.error(err); }); } diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index cbc481a..cd90108 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -144,6 +144,22 @@ describe('/template', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}, + log: { + collection: 'condition_templates', + dataAdd: { + first_id: '200000000000000000000001', + version: 2 + } + } + }); + }); it('allows changing only one property', done => { TestHelper.request(server, done, { method: 'put', @@ -328,6 +344,20 @@ describe('/template', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}, + log: { + collection: 'condition_templates', + dataAdd: {version: 1}, + dataIgn: ['first_id'] + } + }); + }); it('rejects a missing name', done => { TestHelper.request(server, done, { method: 'post', @@ -552,7 +582,7 @@ describe('/template', () => { url: '/template/measurement/300000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, - req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]} }).end((err, res) => { if (err) return done(err); should(_.omit(res.body, '_id')).be.eql({name: 'IR spectrum', version: 2, parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}); @@ -571,6 +601,22 @@ describe('/template', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, + log: { + collection: 'measurement_templates', + dataAdd: { + first_id: '300000000000000000000001', + version: 2 + } + } + }); + }); it('allows changing only one property', done => { TestHelper.request(server, done, { method: 'put', @@ -747,6 +793,20 @@ describe('/template', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]}, + log: { + collection: 'measurement_templates', + dataAdd: {version: 1}, + dataIgn: ['first_id'] + } + }); + }); it('rejects a missing name', done => { TestHelper.request(server, done, { method: 'post', diff --git a/src/routes/template.ts b/src/routes/template.ts index 2873946..c3bd14b 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -7,6 +7,7 @@ import MeasurementTemplateModel from '../models/measurement_template'; import res400 from './validate/res400'; import IdValidate from './validate/id'; import mongoose from "mongoose"; +import db from '../db'; @@ -52,6 +53,7 @@ router.put('/template/:collection(measurement|condition)/' + IdValidate.paramete template.version = templateData.version + 1; // increase version await new (model(req))(_.assign({}, _.omit(templateData, ['_id', '__v']), template)).save((err, data) => { // save new template, fill with old properties if (err) next (err); + db.log(req, req.params.collection + '_templates', {_id: data._id}, data.toObject()); res.json(TemplateValidate.output(data.toObject())); }); } @@ -71,6 +73,7 @@ router.post('/template/:collection(measurement|condition)/new', async (req, res, template.version = 1; // set template version await new (model(req))(template).save((err, data) => { if (err) next (err); + db.log(req, req.params.collection + '_templates', {_id: data._id}, data.toObject()); res.json(TemplateValidate.output(data.toObject())); }); }); diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index 917b734..79c0769 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -3,6 +3,7 @@ import UserModel from '../models/user'; import TestHelper from "../test/helper"; + describe('/user', () => { let server; before(done => TestHelper.before(done)); @@ -199,6 +200,19 @@ describe('/user', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'adminnew', email: 'adminnew@bosch.com', pass: 'Abc123##', location: 'Abt', device_name: 'test'}, + log: { + collection: 'users', + dataIgn: ['pass'] + } + }); + }); it('lets the admin change a user level', done => { TestHelper.request(server, done, { method: 'put', @@ -370,6 +384,17 @@ describe('/user', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user', + auth: {basic: 'janedoe'}, + httpStatus: 200, + log: { + collection: 'users' + } + }); + }); it('rejects requests from non-admins for another user', done => { TestHelper.request(server, done, { method: 'delete', @@ -482,6 +507,19 @@ describe('/user', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}, + log: { + collection: 'users', + dataIgn: ['pass', 'key'] + } + }); + }); it('rejects a username already in use', done => { TestHelper.request(server, done, { method: 'post', @@ -587,6 +625,18 @@ describe('/user', () => { res: {status: 'OK'} }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/passreset', + httpStatus: 200, + req: {email: 'jane.doe@bosch.com', name: 'janedoe'}, + log: { + collection: 'users', + dataIgn: ['email', 'name', 'pass'] + } + }); + }); it('returns 404 for wrong username/email combo', done => { TestHelper.request(server, done, { method: 'post', diff --git a/src/routes/user.ts b/src/routes/user.ts index 6ebed4b..65c41d5 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -7,6 +7,7 @@ import UserValidate from './validate/user'; import UserModel from '../models/user'; import mail from '../helpers/mail'; import res400 from './validate/res400'; +import db from '../db'; const router = express.Router(); @@ -53,7 +54,7 @@ router.put('/user:username([/](?!key|new).?*|/?)', async (req, res, next) => { if (!await usernameCheck(user.name, res, next)) return; } - await UserModel.findOneAndUpdate({name: username}, user, {new: true}).lean().exec( (err, data:any) => { + await UserModel.findOneAndUpdate({name: username}, user, {new: true}).log(req).lean().exec( (err, data:any) => { if (err) return next(err); if (data) { res.json(UserValidate.output(data)); @@ -70,7 +71,7 @@ router.delete('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // const username = getUsername(req, res); if (!username) return; - UserModel.findOneAndDelete({name: username}).lean().exec( (err, data:any) => { + UserModel.findOneAndDelete({name: username}).log(req).lean().exec( (err, data:any) => { if (err) return next(err); if (data) { res.json({status: 'OK'}) @@ -105,6 +106,7 @@ router.post('/user/new', async (req, res, next) => { user.pass = hash; new UserModel(user).save((err, data) => { // store user if (err) return next(err); + db.log(req, 'users', {_id: data._id}, data.toObject()); res.json(UserValidate.output(data.toObject())); }); }); @@ -119,7 +121,7 @@ router.post('/user/passreset', (req, res, next) => { bcrypt.hash(newPass, 10, (err, hash) => { // password hashing if (err) return next(err); - UserModel.findByIdAndUpdate(data[0]._id, {pass: hash}, err => { // write new password + UserModel.findByIdAndUpdate(data[0]._id, {pass: hash}).log(req).exec(err => { // write new password if (err) return next(err); // send email diff --git a/src/routes/validate/root.ts b/src/routes/validate/root.ts new file mode 100644 index 0000000..3d05f9b --- /dev/null +++ b/src/routes/validate/root.ts @@ -0,0 +1,50 @@ +import Joi from '@hapi/joi'; +import IdValidate from './id'; + +export default class RootValidate { // validate input for root methods + private static changelog = { + timestamp: Joi.date() + .iso() + .min('1970-01-01T00:00:00.000Z'), + + page: Joi.number() + .integer() + .min(0) + .default(0), + + pagesize: Joi.number() + .integer() + .min(0) + .default(25), + + action: Joi.string(), + + collection: Joi.string(), + + conditions: Joi.object(), + + data: Joi.object() + }; + + static changelogParams (data) { + return Joi.object({ + timestamp: this.changelog.timestamp.required(), + page: this.changelog.page, + pagesize: this.changelog.pagesize + }).validate(data); + } + + static changelogOutput (data) { + data.date = data._id.getTimestamp(); + data.collection = data.collectionName; + data = IdValidate.stringify(data); + const {value, error} = Joi.object({ + date: this.changelog.timestamp, + action: this.changelog.action, + collection: this.changelog.collection, + conditions: this.changelog.conditions, + data: this.changelog.data, + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 9cb8cbb..58c33ba 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -69,7 +69,7 @@ export default class SampleValidate { } else if (param === 'new-admin') { return Joi.object({ - number: this.sample.number.required(), + number: this.sample.number, color: this.sample.color.required(), type: this.sample.type.required(), batch: this.sample.batch.required(), diff --git a/src/test/db.json b/src/test/db.json index ea2dd10..ef26a63 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -596,6 +596,64 @@ "key": "000000000000000000001004", "__v": 0 } + ], + "changelogs": [ + { + "_id" : {"$oid": "120000010000000000000000"}, + "action" : "PUT /sample/400000000000000000000001", + "collectionName" : "samples", + "conditions" : { + "_id" : {"$oid": "400000000000000000000001"} + }, + "data" : { + "type" : "part", + "status" : 0 + }, + "user_id" : {"$oid": "000000000000000000000003"}, + "__v" : 0 + }, + { + "_id" : {"$oid": "120000020000000000000000"}, + "action" : "PUT /sample/400000000000000000000001", + "collectionName" : "samples", + "conditions" : { + "_id" : {"$oid": "400000000000000000000001"} + }, + "data" : { + "type" : "part", + "status" : 0 + }, + "user_id" : {"$oid": "000000000000000000000003"}, + "__v" : 0 + }, + { + "_id" : {"$oid": "120000030000000000000000"}, + "action" : "PUT /sample/400000000000000000000001", + "collectionName" : "samples", + "conditions" : { + "_id" : {"$oid": "400000000000000000000001"} + }, + "data" : { + "type" : "part", + "status" : 0 + }, + "user_id" : {"$oid": "000000000000000000000003"}, + "__v" : 0 + }, + { + "_id" : {"$oid": "120000040000000000000000"}, + "action" : "PUT /sample/400000000000000000000001", + "collectionName" : "samples", + "conditions" : { + "_id" : {"$oid": "400000000000000000000001"} + }, + "data" : { + "type" : "part", + "status" : 0 + }, + "user_id" : {"$oid": "000000000000000000000003"}, + "__v" : 0 + } ] } } \ No newline at end of file diff --git a/src/test/helper.ts b/src/test/helper.ts index fbb45ff..e1e8eec 100644 --- a/src/test/helper.ts +++ b/src/test/helper.ts @@ -1,14 +1,17 @@ import supertest from 'supertest'; import should from 'should/as-function'; -import db from "../db"; +import _ from 'lodash'; +import db from '../db'; +import ChangelogModel from '../models/changelog'; +import IdValidate from '../routes/validate/id'; export default class TestHelper { public static auth = { // test user credentials - admin: {pass: 'Abc123!#', key: '000000000000000000001003'}, - janedoe: {pass: 'Xyz890*)', key: '000000000000000000001002'}, - user: {pass: 'Xyz890*)', key: '000000000000000000001001'}, - johnnydoe: {pass: 'Xyz890*)', key: '000000000000000000001004'} + admin: {pass: 'Abc123!#', key: '000000000000000000001003', id: '000000000000000000000003'}, + janedoe: {pass: 'Xyz890*)', key: '000000000000000000001002', id: '000000000000000000000002'}, + user: {pass: 'Xyz890*)', key: '000000000000000000001001', id: '000000000000000000000001'}, + johnnydoe: {pass: 'Xyz890*)', key: '000000000000000000001004', id: '000000000000000000000004'} } public static res = { // default responses @@ -92,6 +95,35 @@ export default class TestHelper { done(); }); } + else if (options.hasOwnProperty('log')) { // check changelog, takes log: {collection, skip, data/(dataAdd, dataIgn)} + return st.end(err => { + if (err) return done (err); + ChangelogModel.findOne({}).sort({_id: -1}).skip(options.log.skip? options.log.skip : 0).lean().exec((err, data) => { // latest entry + if (err) return done(err); + should(data).have.only.keys('_id', 'action', 'collectionName', 'conditions', 'data', 'user_id', '__v'); + should(data).have.property('action', options.method.toUpperCase() + ' ' + options.url); + should(data).have.property('collectionName', options.log.collection); + if (options.log.hasOwnProperty('data')) { + should(data).have.property('data', options.log.data); + } + else { + const ignore = ['_id', '__v']; + if (options.log.hasOwnProperty('dataIgn')) { + ignore.push(...options.log.dataIgn); + } + let tmp = options.req ? options.req : {}; + if (options.log.hasOwnProperty('dataAdd')) { + _.assign(tmp, options.log.dataAdd) + } + should(IdValidate.stringify(_.omit(data.data, ignore))).be.eql(_.omit(tmp, ignore)); + } + if (data.user_id) { + should(data.user_id.toString()).be.eql(this.auth[options.auth.basic].id); + } + done(); + }); + }); + } else { // return object to do .end() manually return st; } From 99be1798d0a3c90d297df210e9f15fd877b4508f Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 5 Jun 2020 10:51:03 +0200 Subject: [PATCH 64/83] cleaned TODOS --- build.bat | 4 + data_import/import.js | 0 package-lock.json | 169 ++++++++++++++++++++++++++++++++++++++ package.json | 33 ++++---- src/api.ts | 2 +- src/index.ts | 11 +-- src/routes/sample.spec.ts | 2 - 7 files changed, 196 insertions(+), 25 deletions(-) create mode 100644 build.bat create mode 100644 data_import/import.js diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..d632b14 --- /dev/null +++ b/build.bat @@ -0,0 +1,4 @@ +call npm run tsc-full +copy package.json dist\package.json +Xcopy /E /I api dist\api +Xcopy /E /I static dist\static \ No newline at end of file diff --git a/data_import/import.js b/data_import/import.js new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index 6d935ee..d3b646e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -678,6 +678,11 @@ "type-is": "~1.6.17" } }, + "bowser": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.9.0.tgz", + "integrity": "sha512-2ld76tuLBNFekRgmJfT2+3j5MIrP6bFict8WAIT3beq+srz1gcKNAdNKMqHqauQt63NmAa88HfP1/Ypa9Er3HA==" + }, "boxen": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz", @@ -861,6 +866,11 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" }, + "camelize": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.0.tgz", + "integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=" + }, "cfenv": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cfenv/-/cfenv-1.2.2.tgz", @@ -998,6 +1008,35 @@ "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", "dev": true }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "requires": { + "mime-db": ">= 1.43.0 < 2" + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + } + } + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1029,6 +1068,11 @@ "resolved": "https://registry.npmjs.org/content-filter/-/content-filter-1.1.2.tgz", "integrity": "sha512-VaZ4Y7h776r0v2WxWqu3iatjYI6/N0msXK8O1ymtkFWbSvaFoCePksS8U60BS6dUMZeAlqhN09SuM7ghdzRP1Q==" }, + "content-security-policy-builder": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-security-policy-builder/-/content-security-policy-builder-2.1.0.tgz", + "integrity": "sha512-/MtLWhJVvJNkA9dVLAp6fg9LxD2gfI6R2Fi1hPmfjYXSahJJzcfvoeDOxSyp4NvxMuwWv3WMssE9o31DoULHrQ==" + }, "content-type": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", @@ -1092,6 +1136,11 @@ "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==" }, + "dasherize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dasherize/-/dasherize-2.0.0.tgz", + "integrity": "sha1-bYCcnNDPe7iVLYD8hPoT1H3bEwg=" + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1163,6 +1212,16 @@ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==" }, + "dns-prefetch-control": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dns-prefetch-control/-/dns-prefetch-control-0.2.0.tgz", + "integrity": "sha512-hvSnros73+qyZXhHFjx2CMLwoj3Fe7eR9EJsFsqmcI1bB2OBWL/+0YzaEaKssCHnj/6crawNnUyw74Gm2EKe+Q==" + }, + "dont-sniff-mimetype": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/dont-sniff-mimetype/-/dont-sniff-mimetype-1.1.0.tgz", + "integrity": "sha512-ZjI4zqTaxveH2/tTlzS1wFp+7ncxNZaIEWYg3lzZRHkKf5zPT/MnEG6WL0BhHMJUabkh8GeU5NL5j+rEUCb7Ug==" + }, "dot-prop": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz", @@ -1265,6 +1324,11 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, + "expect-ct": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/expect-ct/-/expect-ct-0.2.0.tgz", + "integrity": "sha512-6SK3MG/Bbhm8MsgyJAylg+ucIOU71/FzyFalcfu5nY19dH8y/z0tBJU0wrNBXD4B27EoQtqPF/9wqH0iYAd04g==" + }, "express": { "version": "4.17.1", "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", @@ -1308,6 +1372,11 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "dev": true }, + "feature-policy": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/feature-policy/-/feature-policy-0.3.0.tgz", + "integrity": "sha512-ZtijOTFN7TzCujt1fnNhfWPFPSHeZkesff9AXZj+UEjYBynWNUIYpC87Ve4wHzyexQsImicLu7WsC2LHq7/xrQ==" + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -1409,6 +1478,11 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" }, + "frameguard": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/frameguard/-/frameguard-3.1.0.tgz", + "integrity": "sha512-TxgSKM+7LTA6sidjOiSZK9wxY0ffMPY3Wta//MqwmX0nZuEHc8QrkV8Fh3ZhMJeiH+Uyh/tcaarImRy8u77O7g==" + }, "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -1568,6 +1642,76 @@ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true }, + "helmet": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-3.22.0.tgz", + "integrity": "sha512-Xrqicn2nm1ZIUxP3YGuTBmbDL04neKsIT583Sjh0FkiwKDXYCMUqGqC88w3NUvVXtA75JyR2Jn6jw6ZEMOD+ZA==", + "requires": { + "depd": "2.0.0", + "dns-prefetch-control": "0.2.0", + "dont-sniff-mimetype": "1.1.0", + "expect-ct": "0.2.0", + "feature-policy": "0.3.0", + "frameguard": "3.1.0", + "helmet-crossdomain": "0.4.0", + "helmet-csp": "2.10.0", + "hide-powered-by": "1.1.0", + "hpkp": "2.0.0", + "hsts": "2.2.0", + "ienoopen": "1.1.0", + "nocache": "2.1.0", + "referrer-policy": "1.2.0", + "x-xss-protection": "1.3.0" + }, + "dependencies": { + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + } + } + }, + "helmet-crossdomain": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/helmet-crossdomain/-/helmet-crossdomain-0.4.0.tgz", + "integrity": "sha512-AB4DTykRw3HCOxovD1nPR16hllrVImeFp5VBV9/twj66lJ2nU75DP8FPL0/Jp4jj79JhTfG+pFI2MD02kWJ+fA==" + }, + "helmet-csp": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/helmet-csp/-/helmet-csp-2.10.0.tgz", + "integrity": "sha512-Rz953ZNEFk8sT2XvewXkYN0Ho4GEZdjAZy4stjiEQV3eN7GDxg1QKmYggH7otDyIA7uGA6XnUMVSgeJwbR5X+w==", + "requires": { + "bowser": "2.9.0", + "camelize": "1.0.0", + "content-security-policy-builder": "2.1.0", + "dasherize": "2.0.0" + } + }, + "hide-powered-by": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hide-powered-by/-/hide-powered-by-1.1.0.tgz", + "integrity": "sha512-Io1zA2yOA1YJslkr+AJlWSf2yWFkKjvkcL9Ni1XSUqnGLr/qRQe2UI3Cn/J9MsJht7yEVCe0SscY1HgVMujbgg==" + }, + "hpkp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hpkp/-/hpkp-2.0.0.tgz", + "integrity": "sha1-EOFCJk52IVpdMMROxD3mTe5tFnI=" + }, + "hsts": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hsts/-/hsts-2.2.0.tgz", + "integrity": "sha512-ToaTnQ2TbJkochoVcdXYm4HOCliNozlviNsg+X2XQLQvZNI/kCHR9rZxVYpJB3UPcHz80PgxRyWQ7PdU1r+VBQ==", + "requires": { + "depd": "2.0.0" + }, + "dependencies": { + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + } + } + }, "html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -1599,6 +1743,11 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "ienoopen": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ienoopen/-/ienoopen-1.1.0.tgz", + "integrity": "sha512-MFs36e/ca6ohEKtinTJ5VvAJ6oDRAYFdYXweUnGY9L9vcoqFOU4n2ZhmJ0C4z/cwGZ3YIQRSB3XZ1+ghZkY5NQ==" + }, "ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -2270,6 +2419,11 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "nocache": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/nocache/-/nocache-2.1.0.tgz", + "integrity": "sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q==" + }, "node-environment-flags": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.6.tgz", @@ -2571,6 +2725,11 @@ "ee-first": "1.1.1" } }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2845,6 +3004,11 @@ "picomatch": "^2.0.7" } }, + "referrer-policy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/referrer-policy/-/referrer-policy-1.2.0.tgz", + "integrity": "sha512-LgQJIuS6nAy1Jd88DCQRemyE3mS+ispwlqMk3b0yjZ257fI1v9c+/p6SD5gP5FGyXUIgrNOAfmyioHwZtYv2VA==" + }, "regexp-clone": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-1.0.0.tgz", @@ -3652,6 +3816,11 @@ "typedarray-to-buffer": "^3.1.5" } }, + "x-xss-protection": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/x-xss-protection/-/x-xss-protection-1.3.0.tgz", + "integrity": "sha512-kpyBI9TlVipZO4diReZMAHWtS0MMa/7Kgx8hwG/EuZLiA6sg4Ah/4TRdASHhRRN3boobzcYgFRUFSgHRge6Qhg==" + }, "xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", diff --git a/package.json b/package.json index 6e7f289..777e274 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,10 @@ "scripts": { "tsc": "tsc", "tsc-full": "del /q dist\\* & (for /d %x in (dist\\*) do @rd /s /q \"%x\") & tsc", + "build": "build.bat", "test": "mocha dist/**/**.spec.js", - "start": "tsc && node dist/index.js || exit 1", - "dev": "nodemon -e ts,yaml --exec \"npm run start\"", + "start": "node index.js", + "dev": "nodemon -e ts,yaml --exec \"tsc && node dist/index.js || exit 1\"", "loadDev": "node dist/test/loadDev.js", "coverage": "tsc && nyc --reporter=html --reporter=text mocha dist/**/**.spec.js --timeout 5000" }, @@ -19,35 +20,37 @@ "@apidevtools/json-schema-ref-parser": "^8.0.0", "@apidevtools/swagger-parser": "^9.0.1", "@hapi/joi": "^17.1.1", - "@types/bcrypt": "^3.0.0", - "@types/body-parser": "^1.19.0", - "@types/express-serve-static-core": "^4.17.5", - "@types/mocha": "^5.2.7", - "@types/mongoose": "^5.7.12", - "@types/node": "^13.1.6", - "@types/qs": "^6.9.1", - "@types/serve-static": "^1.13.3", "axios": "^0.19.2", "basic-auth": "^2.0.1", "bcryptjs": "^2.4.3", "body-parser": "^1.19.0", "cfenv": "^1.2.2", + "compression": "^1.7.4", "content-filter": "^1.1.2", "express": "^4.17.1", + "helmet": "^3.22.0", "json-schema": "^0.2.5", "lodash": "^4.17.15", "mongo-sanitize": "^1.1.0", "mongoose": "^5.8.7", - "nodemon": "^2.0.3", - "swagger-ui-express": "^4.1.2", - "tslint": "^5.20.1", - "typescript": "^3.7.4" + "swagger-ui-express": "^4.1.2" }, "devDependencies": { + "@types/bcrypt": "^3.0.0", + "@types/body-parser": "^1.19.0", + "@types/express-serve-static-core": "^4.17.5", "@types/lodash": "^4.14.150", + "@types/mocha": "^5.2.7", + "@types/mongoose": "^5.7.12", + "@types/node": "^13.1.6", + "@types/qs": "^6.9.1", + "@types/serve-static": "^1.13.3", "mocha": "^7.1.2", + "nodemon": "^2.0.3", "nyc": "^15.0.1", "should": "^13.2.3", - "supertest": "^4.0.2" + "supertest": "^4.0.2", + "tslint": "^5.20.1", + "typescript": "^3.7.4" } } diff --git a/src/api.ts b/src/api.ts index 59ce0b3..0867bc1 100644 --- a/src/api.ts +++ b/src/api.ts @@ -16,7 +16,7 @@ export default class api { static setup () { let apiDoc: JSONSchema = {}; jsonRefParser.bundle('api/api.yaml', (err, doc) => { // parse yaml - if(err) throw err; + if (err) throw err; apiDoc = doc; apiDoc.paths = apiDoc.paths.allOf.reduce((s, e) => Object.assign(s, e)); // bundle routes apiDoc = this.resolveXDoc(apiDoc); diff --git a/src/index.ts b/src/index.ts index 4cf4f45..d274b89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,12 @@ import express from 'express'; import bodyParser from 'body-parser'; +import compression from 'compression'; import contentFilter from 'content-filter'; import mongoSanitize from 'mongo-sanitize'; +import helmet from 'helmet'; import api from './api'; import db from './db'; -// TODO: check executing index.js/move everything needed into dist -// TODO: validation: VZ, Humidity: min/max value, DPT: filename -// TODO: add multiple samples at once (only GUI) -// TODO: think about the display of deleted/new samples and validation in data and UI -// TODO: improve error coverage -// TODO: guess properties from material name in UI -// TODO: mongodb user // tell if server is running in debug or production environment console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); @@ -28,8 +23,10 @@ app.disable('x-powered-by'); const port = process.env.PORT || 3000; //middleware +app.use(helmet()); app.use(express.json({ limit: '5mb'})); app.use(express.urlencoded({ extended: false, limit: '5mb' })); +app.use(compression()); // compress responses app.use(bodyParser.json()); app.use(contentFilter()); // filter URL query attacks app.use((req, res, next) => { // filter body query attacks diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 9ce2a88..97b9eb3 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -9,9 +9,7 @@ import mongoose from 'mongoose'; // TODO: generate output for ML in format DPT -> data, implement filtering, field selection // TODO: generate csv -// TODO: filter by not completely filled/no measurements // TODO: write script for data import -// TODO: allow adding sample numbers for existing samples describe('/sample', () => { let server; From c95af7bc0b58684034a02a98de39ba60880b328d Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 15 Jun 2020 12:49:32 +0200 Subject: [PATCH 65/83] added status filter --- api/sample.yaml | 9 +++++++++ src/helpers/authorize.ts | 1 + src/index.ts | 2 +- src/routes/sample.spec.ts | 34 ++++++++++++++++++++++++++++++++++ src/routes/sample.ts | 19 ++++++++++++++++++- src/routes/validate/sample.ts | 6 ++++++ 6 files changed, 69 insertions(+), 2 deletions(-) diff --git a/api/sample.yaml b/api/sample.yaml index eae0ddc..1810a65 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -5,6 +5,13 @@ x-doc: returns only samples with status 10 tags: - /sample + parameters: + - name: status + description: 'values: validated|new|all, defaults to validated' + in: query + schema: + type: string + example: all responses: 200: description: samples overview @@ -14,6 +21,8 @@ type: array items: $ref: 'api.yaml#/components/schemas/SampleRefs' + 400: + $ref: 'api.yaml#/components/responses/400' 401: $ref: 'api.yaml#/components/responses/401' 500: diff --git a/src/helpers/authorize.ts b/src/helpers/authorize.ts index 21d43d5..71a42c2 100644 --- a/src/helpers/authorize.ts +++ b/src/helpers/authorize.ts @@ -89,6 +89,7 @@ function key (req, next): any { // checks API key and returns changed user obje if (err) return next(err); if (data.length === 1) { // one user found resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString(), location: data[0].location}); + delete req.query.key; // delete query parameter to avoid interference with later validation } else { resolve(null); diff --git a/src/index.ts b/src/index.ts index d274b89..9af77cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,7 +48,7 @@ app.use(require('./helpers/authorize')); // handle authentication // redirect /api routes for Angular proxy in development if (process.env.NODE_ENV !== 'production') { - app.use('/api/:url', (req, res) => { + app.use('/api/:url([^]+)', (req, res) => { req.url = '/' + req.params.url; app.handle(req, res); }); diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 97b9eb3..ee4b07e 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -71,6 +71,40 @@ describe('/sample', () => { done(); }); }); + it('allows filtering by state', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=new', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.new).length); + should(res.body).matchEach(sample => { + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); + should(sample).have.property('_id').be.type('string'); + should(sample).have.property('number').be.type('string'); + should(sample).have.property('type').be.type('string'); + should(sample).have.property('color').be.type('string'); + should(sample).have.property('batch').be.type('string'); + should(sample).have.property('condition').be.type('object'); + should(sample).have.property('material_id').be.type('string'); + should(sample).have.property('note_id'); + should(sample).have.property('user_id').be.type('string'); + }); + done(); + }); + }); + it('rejects an invalid state name', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=xxx', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"status" must be one of [validated, new, all]'} + }); + }); it('rejects unauthorized requests', done => { TestHelper.request(server, done, { method: 'get', diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 3966c9b..487711b 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -22,7 +22,24 @@ const router = express.Router(); router.get('/samples', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - SampleModel.find({status: globals.status.validated}).lean().exec((err, data) => { + const {error, value: filters} = SampleValidate.query(req.query); + if (error) return res400(error, res); + + let conditions; + + if (filters.hasOwnProperty('status')) { + if(filters.status === 'all') { + conditions = {$or: [{status: globals.status.validated}, {status: globals.status.new}]} + } + else { + conditions = {status: globals.status[filters.status]}; + } + } + else { // default + conditions = {status: globals.status.validated}; + } + + SampleModel.find(conditions).lean().exec((err, data) => { if (err) return next(err); res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors }) diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 58c33ba..616e060 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -118,4 +118,10 @@ export default class SampleValidate { const {value, error} = Joi.object(joiObject).validate(data, {stripUnknown: true}); return error !== undefined? null : value; } + + static query (data) { + return Joi.object({ + status: Joi.string().valid('validated', 'new', 'all') + }).validate(data); + } } \ No newline at end of file From 869a675840fe54f0c1e169111c645b4e9db453ea Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Wed, 17 Jun 2020 13:42:14 +0200 Subject: [PATCH 66/83] added status filter for materials --- api/material.yaml | 7 +++++++ src/routes/material.spec.ts | 37 +++++++++++++++++++++++++++++++++ src/routes/material.ts | 19 ++++++++++++++++- src/routes/validate/material.ts | 6 ++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/api/material.yaml b/api/material.yaml index 378628d..593afb1 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -5,6 +5,13 @@ x-doc: returns only materials with status 10 tags: - /material + parameters: + - name: status + description: 'values: validated|new|all, defaults to validated' + in: query + schema: + type: string + example: all responses: 200: description: all material details diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index e91e87e..e412615 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -72,6 +72,43 @@ describe('/material', () => { done(); }); }); + it('allows filtering by state', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials?status=new', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === globals.status.new).length); + should(res.body).matchEach(material => { + should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers'); + should(material).have.property('_id').be.type('string'); + should(material).have.property('name').be.type('string'); + should(material).have.property('supplier').be.type('string'); + should(material).have.property('group').be.type('string'); + should(material).have.property('mineral').be.type('number'); + should(material).have.property('glass_fiber').be.type('number'); + should(material).have.property('carbon_fiber').be.type('number'); + should(material.numbers).matchEach(number => { + should(number).have.only.keys('color', 'number'); + should(number).have.property('color').be.type('string'); + should(number).have.property('number').be.type('string'); + }); + }); + done(); + }); + }); + it('rejects an invalid state name', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/materials?status=xxx', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"status" must be one of [validated, new, all]'} + }); + }); it('rejects unauthorized requests', done => { TestHelper.request(server, done, { method: 'get', diff --git a/src/routes/material.ts b/src/routes/material.ts index 8373c9d..3f34e3a 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -19,7 +19,24 @@ const router = express.Router(); router.get('/materials', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - MaterialModel.find({status:globals.status.validated}).populate('group_id').populate('supplier_id').lean().exec((err, data) => { + const {error, value: filters} = MaterialValidate.query(req.query); + if (error) return res400(error, res); + + let conditions; + + if (filters.hasOwnProperty('status')) { + if(filters.status === 'all') { + conditions = {$or: [{status: globals.status.validated}, {status: globals.status.new}]} + } + else { + conditions = {status: globals.status[filters.status]}; + } + } + else { // default + conditions = {status: globals.status.validated}; + } + + MaterialModel.find(conditions).populate('group_id').populate('supplier_id').lean().exec((err, data) => { if (err) return next(err); res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors diff --git a/src/routes/validate/material.ts b/src/routes/validate/material.ts index 7a2c3fb..969ac43 100644 --- a/src/routes/validate/material.ts +++ b/src/routes/validate/material.ts @@ -107,4 +107,10 @@ export default class MaterialValidate { // validate input for material numbers: this.material.numbers }); } + + static query (data) { + return Joi.object({ + status: Joi.string().valid('validated', 'new', 'all') + }).validate(data); + } } \ No newline at end of file From ac72d8a9752dfa7c00bf08d1153925742408a948 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 18 Jun 2020 08:57:50 +0200 Subject: [PATCH 67/83] fixed validation to return measurements in /sample/{id} --- api/sample.yaml | 18 ++++++++++-------- api/schemas.yaml | 2 ++ src/routes/sample.spec.ts | 6 +++--- src/routes/sample.ts | 2 +- src/routes/validate/measurement.ts | 9 +++++++++ src/routes/validate/sample.ts | 2 ++ 6 files changed, 27 insertions(+), 12 deletions(-) diff --git a/api/sample.yaml b/api/sample.yaml index 1810a65..9331806 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -55,8 +55,8 @@ parameters: - $ref: 'api.yaml#/components/parameters/Id' get: - summary: TODO sample details - description: 'Auth: all, levels: read, write, maintain, dev, admin' + summary: sample details + description: 'Auth: all, levels: read, write, maintain, dev, admin
    Returns validated as well as new measurements' x-doc: deleted samples are available only for maintain/admin tags: - /sample @@ -225,12 +225,14 @@ content: application/json: schema: - properties: - name: - type: string - qty: - type: number - example: 20 + type: array + items: + properties: + name: + type: string + qty: + type: number + example: 20 401: $ref: 'api.yaml#/components/responses/401' 500: diff --git a/api/schemas.yaml b/api/schemas.yaml index 21ceddf..9704e08 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -69,6 +69,8 @@ Sample: relation: type: string example: part to this sample + custom_fields: + type: object SampleDetail: allOf: diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index ee4b07e..9a483a4 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -215,7 +215,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000003', auth: {basic: 'janedoe'}, httpStatus: 200, - res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, user: 'admin'} + res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, measurements: [{_id: '800000000000000000000003', sample_id: '400000000000000000000003', values: {val1: 1}, measurement_template: '300000000000000000000003'}], user: 'admin'} }); }); it('works with an API key', done => { @@ -224,7 +224,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000003', auth: {key: 'janedoe'}, httpStatus: 200, - res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, user: 'admin'} + res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, measurements: [{_id: '800000000000000000000003', sample_id: '400000000000000000000003', values: {val1: 1}, measurement_template: '300000000000000000000003'}], user: 'admin'} }); }); it('returns a deleted sample for a maintain/admin user', done => { @@ -233,7 +233,7 @@ describe('/sample', () => { url: '/sample/400000000000000000000005', auth: {basic: 'admin'}, httpStatus: 200, - res: {_id: '400000000000000000000005', number: 'Rng33', type: 'granulate', color: 'black', batch: '1653000308', condition: {condition_template: '200000000000000000000003'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {}, user: 'admin'} + res: {_id: '400000000000000000000005', number: 'Rng33', type: 'granulate', color: 'black', batch: '1653000308', condition: {condition_template: '200000000000000000000003'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {}, measurements: [], user: 'admin'} }); }); it('returns 403 for a write user when requesting a deleted sample', done => { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 487711b..d28e725 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -71,7 +71,7 @@ router.get('/sample/' + IdValidate.parameter(), (req, res, next) => { sampleData.material.supplier = sampleData.material.supplier_id.name; sampleData.user = sampleData.user_id.name; sampleData.notes = sampleData.note_id ? sampleData.note_id : {}; - MeasurementModel.find({sample_id: mongoose.Types.ObjectId(req.params.id)}).lean().exec((err, data) => { + MeasurementModel.find({sample_id: mongoose.Types.ObjectId(req.params.id), status: {$ne: globals.status.deleted}}).lean().exec((err, data) => { sampleData.measurements = data; res.json(SampleValidate.output(sampleData, 'details')); }); diff --git a/src/routes/validate/measurement.ts b/src/routes/validate/measurement.ts index 74c2409..0af8fbd 100644 --- a/src/routes/validate/measurement.ts +++ b/src/routes/validate/measurement.ts @@ -44,4 +44,13 @@ export default class MeasurementValidate { }).validate(data, {stripUnknown: true}); return error !== undefined? null : value; } + + static outputV() { // return output validator + return Joi.object({ + _id: IdValidate.get(), + sample_id: IdValidate.get(), + values: this.measurement.values, + measurement_template: IdValidate.get() + }); + } } \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 616e060..1da56da 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -3,6 +3,7 @@ import Joi from '@hapi/joi'; import IdValidate from './id'; import UserValidate from './user'; import MaterialValidate from './material'; +import MeasurementValidate from './measurement'; export default class SampleValidate { private static sample = { @@ -108,6 +109,7 @@ export default class SampleValidate { batch: this.sample.batch, condition: this.sample.condition, material: MaterialValidate.outputV(), + measurements: Joi.array().items(MeasurementValidate.outputV()), notes: this.sample.notes, user: UserValidate.username() } From cd2962e186fc68d42aa75815ab73d451bd1fab44 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 25 Jun 2020 10:44:55 +0200 Subject: [PATCH 68/83] implemented paging --- api/sample.yaml | 20 ++ api/schemas.yaml | 3 + data_import/import.js | 485 ++++++++++++++++++++++++++++++++ package-lock.json | 505 ++++++++++++++++++++++++++++------ package.json | 7 +- src/routes/sample.spec.ts | 154 +++++++++-- src/routes/sample.ts | 33 ++- src/routes/validate/sample.ts | 17 +- 8 files changed, 1111 insertions(+), 113 deletions(-) diff --git a/api/sample.yaml b/api/sample.yaml index 9331806..4ebb61f 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -12,6 +12,24 @@ schema: type: string example: all + - name: last-id + description: last id of current page, if not given the results are displayed from start + in: query + schema: + type: string + example: 5ea0450ed851c30a90e70894 + - name: to-page + description: relative change of pages, use negative values to get back, defaults to 0 (if last-id is given, the sample after is the first of the result, so the next page is selected automatically), works only together with page-size + in: query + schema: + type: string + example: 1 + - name: page-size + description: number of items per page + in: query + schema: + type: string + example: 30 responses: 200: description: samples overview @@ -25,6 +43,8 @@ $ref: 'api.yaml#/components/responses/400' 401: $ref: 'api.yaml#/components/responses/401' + 404: + $ref: 'api.yaml#/components/responses/404' 500: $ref: 'api.yaml#/components/responses/500' diff --git a/api/schemas.yaml b/api/schemas.yaml index 9704e08..99f7998 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -46,6 +46,9 @@ SampleRefs: $ref: 'api.yaml#/components/schemas/Id' user_id: $ref: 'api.yaml#/components/schemas/Id' + added: + type: string + example: 1970-01-01T00:00:00.000Z Sample: allOf: - $ref: 'api.yaml#/components/schemas/_Id' diff --git a/data_import/import.js b/data_import/import.js index e69de29..28614c8 100644 --- a/data_import/import.js +++ b/data_import/import.js @@ -0,0 +1,485 @@ +const csv = require('csv-parser'); +const fs = require('fs'); +const axios = require('axios'); +const {Builder} = require('selenium-webdriver'); +const chrome = require('selenium-webdriver/chrome'); +const pdfReader = require('pdfreader'); +const iconv = require('iconv-lite'); + +const metadata = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200622\\VZ.csv'; // metadata file +const nmDocs = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200622\\nmDocs'; // NormMaster Documents +const dptFiles = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200622\\DPT'; // Spectrum files +let data = []; // metadata contents +let materials = {}; +let samples = []; +let normMaster = {}; + +// TODO: integrate measurement device information from DPT names using different users +// TODO: supplier: other for supplierless samples + +main(); + +async function main() { + if (0) { // materials + await getNormMaster(); + await importCsv(); + await allMaterials(); + fs.writeFileSync('./data_import/materials.json', JSON.stringify(materials)); + await saveMaterials(); + } + else if (0) { // samples + await importCsv(); + await allSamples(); + await saveSamples(); + } + else if (0) { // DPT + await allDpts(); + } + else if (1) { // KF/VZ + await importCsv(); + await allKfVz(); + } + else if (0) { // pdf test + console.log(await readPdf('N28_BN22-O010_2018-03-08.pdf')); + } +} + +async function importCsv() { + await new Promise(resolve => { + fs.createReadStream(metadata) + .pipe(iconv.decodeStream('win1252')) + .pipe(csv()) + .on('data', (row) => { + data.push(row); + }) + .on('end', () => { + console.info('CSV file successfully processed'); + resolve(); + }); + }); +} + +async function allDpts() { + let res = await axios({ + method: 'get', + url: 'http://localhost:3000/template/measurements', + auth: { + username: 'admin', + password: 'Abc123!#' + } + }); + const measurement_template = res.data.find(e => e.name === 'spectrum')._id; + res = await axios({ + method: 'get', + url: 'http://localhost:3000/samples?status=all', + auth: { + username: 'admin', + password: 'Abc123!#' + } + }); + const sampleIds = {}; + res.data.forEach(sample => { + sampleIds[sample.number] = sample._id; + }); + const regex = /.*?_(.*?)_(\d+|\d+_\d+).DPT/; + const dpts = fs.readdirSync(dptFiles); + for (let i in dpts) { + const regexRes = regex.exec(dpts[i]) + if (regexRes && sampleIds[regexRes[1]]) { // found matching sample + console.log(dpts[i]); + const f = fs.readFileSync(dptFiles + '\\' + dpts[i], 'utf-8'); + const data = { + sample_id: sampleIds[regexRes[1]], + values: {}, + measurement_template + }; + data.values.dpt = f.split('\r\n').map(e => e.split(',')); + await axios({ + method: 'post', + url: 'http://localhost:3000/measurement/new', + auth: { + username: 'admin', + password: 'Abc123!#' + }, + data + }).catch(err => { + console.log(dpts[i]); + console.error(err.response.data); + }); + } + } +} + +async function allKfVz() { + let res = await axios({ + method: 'get', + url: 'http://localhost:3000/template/measurements', + auth: { + username: 'admin', + password: 'Abc123!#' + } + }); + const kf_template = res.data.find(e => e.name === 'kf')._id; + const vz_template = res.data.find(e => e.name === 'vz')._id; + res = await axios({ + method: 'get', + url: 'http://localhost:3000/samples?status=all', + auth: { + username: 'admin', + password: 'Abc123!#' + } + }); + const sampleIds = {}; + res.data.forEach(sample => { + sampleIds[sample.number] = sample._id; + }); + for (let index in data) { + console.info(`${index}/${data.length}`); + let sample = data[index]; + if (sample['Sample number'] !== '') { + if (sample['KF in Gew%']) { + await axios({ + method: 'post', + url: 'http://localhost:3000/measurement/new', + auth: { + username: 'admin', + password: 'Abc123!#' + }, + data: { + sample_id: sampleIds[sample['Sample number']], + measurement_template: kf_template, + values: { + 'weight %': sample['KF in Gew%'], + 'standard deviation': sample['Stabwn'] + } + } + }).catch(err => { + console.log(sample['Sample number']); + console.error(err.response.data); + }); + } + if (sample['VZ (ml/g)']) { + await axios({ + method: 'post', + url: 'http://localhost:3000/measurement/new', + auth: { + username: 'admin', + password: 'Abc123!#' + }, + data: { + sample_id: sampleIds[sample['Sample number']], + measurement_template: vz_template, + values: { + vz: sample['VZ (ml/g)'] + } + } + }).catch(err => { + console.log(sample['Sample number']); + console.error(err.response.data); + }); + } + } + } +} + +async function allSamples() { + let res = await axios({ + method: 'get', + url: 'http://localhost:3000/materials?status=all', + auth: { + username: 'admin', + password: 'Abc123!#' + } + }); + const dbMaterials = {} + res.data.forEach(m => { + dbMaterials[m.name] = m; + }) + res = await axios({ + method: 'get', + url: 'http://localhost:3000/samples?status=all', + auth: { + username: 'admin', + password: 'Abc123!#' + } + }); + const sampleColors = {}; + res.data.forEach(sample => { + sampleColors[sample.number] = sample.color; + }); + + + for (let index in data) { + console.info(`${index}/${data.length}`); + let sample = data[index]; + if (sample['Sample number'] !== '' && sample['Supplier'] !== '' && sample['Granulate/Part'] !== '') { // TODO: wait for decision about samples without suppliers/color/type + const material = dbMaterials[trim(sample['Material name'])]; + if (!material) { // could not find material, skipping sample + continue; + } + console.log(sample['Material name']); + console.log(material._id); + samples.push({ + number: sample['Sample number'], + type: sample['Granulate/Part'], + batch: sample['Charge/batch granulate/part'] || '', + material_id: material._id, + notes: { + comment: sample['Comments'] + } + }); + const si = samples.length - 1; + if (sample['Material number'] !== '' && material.numbers.find(e => e.number === sample['Material number'])) { // TODO: fix because of false material/material number + samples[si].color = material.numbers.find(e => e.number === sample['Material number']).color; + } + else if (sample['Color'] && sample['Color'] !== '') { + samples[si].color = material.numbers.find(e => e.color.indexOf(sample['Color']) >= 0).color; + } + else if (sampleColors[sample['Sample number'].split('_')[0]]) { // derive color from main sample for kf/vz + samples[si].color = sampleColors[sample['Sample number'].split('_')[0]]; + } + else { // TODO: no color information at all + samples.pop(); + } + } + } +} + +async function saveSamples() { + for (let i in samples) { + console.info(`${i}/${samples.length}`); + await axios({ + method: 'post', + url: 'http://localhost:3000/sample/new', + auth: { + username: 'admin', + password: 'Abc123!#' + }, + data: samples[i] + }).catch(err => { + console.log(samples[i]); + console.error(err.response.data); + }); + } + console.info('saved all samples'); +} + +async function allMaterials() { + for (let index in data) { + let sample = data[index]; + if (sample['Sample number'] !== '' && sample['Supplier'] !== '') { // TODO: wait for decision about supplierless samples + sample['Material name'] = trim(sample['Material name']); + if (materials.hasOwnProperty(sample['Material name'])) { // material already found at least once + if (sample['Material number'] !== '') { + if (materials[sample['Material name']].numbers.length === 0 || !materials[sample['Material name']].numbers.find(e => e.number === stripSpaces(sample['Material number']))) { // new material number + if (materials[sample['Material name']].numbers.find(e => e.color === sample['Color'] && e.number === '')) { // color already in list, only number missing + materials[sample['Material name']].numbers.find(e => e.color === sample['Color'] && e.number === '').number = stripSpaces(sample['Material number']); + } + else { + materials[sample['Material name']].numbers.push({color: sample['Color'], number: stripSpaces(sample['Material number'])}); + } + } + } + else if (sample['Color'] !== '') { + if (!materials[sample['Material name']].numbers.find(e => e.color === stripSpaces(sample['Color']))) { // new material color + materials[sample['Material name']].numbers.push({color: sample['Color'], number: ''}); + } + } + } + else { // new material + console.info(`${index}/${data.length} ${sample['Material name']}`); + materials[sample['Material name']] = { + name: sample['Material name'], + supplier: sample['Supplier'], + group: sample['Material'] + }; + let tmp = /M(\d+)/.exec(sample['Reinforcing material']); + materials[sample['Material name']].mineral = tmp ? tmp[1] : 0; + tmp = /GF(\d+)/.exec(sample['Reinforcing material']); + materials[sample['Material name']].glass_fiber = tmp ? tmp[1] : 0; + tmp = /CF(\d+)/.exec(sample['Reinforcing material']); + materials[sample['Material name']].carbon_fiber = tmp ? tmp[1] : 0; + materials[sample['Material name']].numbers = await numbersFetch(sample); + console.log(materials[sample['Material name']]); + } + } + } +} + +async function saveMaterials() { + const mKeys = Object.keys(materials) + for (let i in mKeys) { + await axios({ + method: 'post', + url: 'http://localhost:3000/material/new', + auth: { + username: 'admin', + password: 'Abc123!#' + }, + data: materials[mKeys[i]] + }).catch(err => { + console.log(materials[mKeys[i]]); + console.error(err.response.data); + }); + } + console.info('saved all materials'); +} + +async function numbersFetch(sample) { + let nm = []; + let res = []; + if (sample['Material number']) { // sample has a material number + nm = normMaster[stripSpaces(sample['Material number'])]? [normMaster[stripSpaces(sample['Material number'])]] : []; + } + else { // try finding via material name + nm = Object.keys(normMaster).filter(e => normMaster[e].nameSpaceless === stripSpaces(sample['Material name'])).map(e => normMaster[e]); + } + if (nm.length > 0) { + for (let i in nm) { + // if (!fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)) { // document not loaded + // await getNormMasterDoc(nm[i].url.replace(/ /g, '%20')); + // } + // if (!fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)) { // document not loaded + // console.info('Retrying download...'); + // await getNormMasterDoc(nm[i].url.replace(/ /g, '%20'), 2.2); + // } + // if (!fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)) { // document not loaded + // console.info('Retrying download again...'); + // await getNormMasterDoc(nm[i].url.replace(/ /g, '%20'), 5); + // } + if (fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)) { // document loaded + res = await readPdf(fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)); + } + if (res.length > 0) { // no results + break; + } + else if (i + 1 >= nm.length) { + console.error('Download failed!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); + } + } + } + if (res.length === 0) { // no results + if (sample['Color'] !== '' || sample['Material number'] !== '') { + return [{color: sample['Color'], number: sample['Material number']}]; + } + else { + return []; + } + } + else { + if (!res.find(e => e.number === sample['Material number'])) { // sometimes norm master does not include sample number even if listed + res.push({color: sample['Color'], number: sample['Material number']}); + } + return res; + } +} + +async function getNormMaster(fetchAgain = false) { + if (fetchAgain) { + console.info('fetching norm master...'); + const res = await axios({ + method: 'get', + url: 'http://rb-normen.bosch.com/cgi-bin/searchRBNorm4TradeName' + }); + + console.info('finding documents...'); + let match; + // const regex = /
    .*?
    .*?<\/span>(.*?)<\/td>(\d+)<\/td>.*?.*?.*?<\/span>(.*?)<\/td>(\d+)<\/td>40.*?(.*?)<\/td>/gm; // only valid materials + do { + match = regex.exec(res.data); + if (match) { + normMaster[match[2]] = {name: match[1], nameSpaceless: stripSpaces(match[1]), number: match[2], url: match[3], doc: match[4]}; + } + } while (match); + fs.writeFileSync('./data_import/normMaster.json', JSON.stringify(normMaster)); + } + else { + normMaster = JSON.parse(fs.readFileSync('./data_import/normMaster.json'), 'utf-8'); + } +} + +function getNormMasterDoc(url, timing = 1) { + console.log(url); + return new Promise(async resolve => { + const options = new chrome.Options(); + options.setUserPreferences({ + "download.default_directory": nmDocs, + "download.prompt_for_download": false, + "download.directory_upgrade": true, + "plugins.always_open_pdf_externally": true + }); + let driver = await new Builder().forBrowser('chrome').setChromeOptions(options).build(); + let timeout = 7000 * timing; + try { + await driver.get(url); + if (await driver.getCurrentUrl() !== 'https://rb-wam-saml.bosch.com/tfim/sps/normmaster/saml20/login') { // got document selection page + timeout = 11000 * timing; + await driver.executeScript('Array.prototype.slice.call(document.querySelectorAll(\'.functionlink\')).filter(e => e.innerText === \'English\')[0].click()').catch(() => {timeout = 0; }); + } + } + finally { + setTimeout(async () => { // wait until download is finished + await driver.quit(); + resolve(); + }, timeout); + } + }); +} + +function readPdf(file) { + return new Promise(async resolve => { + const countdown = 100; // value for text timeout + let table = 0; // > 0 when in correct table area + let rows = []; // found table rows + let lastY = 0; // y of last row + let lastX = 0; // right x of last item + let lastText = ''; // text of last item + let lastLastText = ''; // text of last last item + await new pdfReader.PdfReader().parseFileItems(nmDocs + '\\' + file, (err, item) => { + if (item && item.text) { + if ((stripSpaces(lastLastText + lastText + item.text).toLowerCase().indexOf('colordesignationsupplier') >= 0) || (stripSpaces(lastLastText + lastText + item.text).toLowerCase().indexOf('colordesignatiomsupplier') >= 0)) { // table area starts + table = countdown; + } + if (table > 0) { + // console.log(item); + // console.log(item.y - lastY); + // console.log(item.text); + if (item.y - lastY > 0.8 && Math.abs(item.x - lastX) > 5) { // new row + lastY = item.y; + rows.push(item.text); + } + else { // still the same row row + rows[rows.length - 1] += (item.x - lastX > 1.1 ? '$' : '') + item.text; // push to row, detect if still same cell + } + lastX = (item.w * 0.055) + item.x; + + if (/\d \d\d\d \d\d\d \d\d\d/.test(item.text)) { + table = countdown; + } + table --; + if (table <= 0 || item.text.toLowerCase().indexOf('release document') >= 0 || item.text.toLowerCase().indexOf('normative references') >= 0) { // table area ended + table = -1; + // console.log(rows); + rows = rows.filter(e => /^\d{10}/m.test(stripSpaces(e))); // filter non-table rows + resolve(rows.map(e => {return {color: e.split('$')[3], number: stripSpaces(e.split('$')[0])}; })); + } + } + lastLastText = lastText; + lastText = item.text; + } + if (!item && table !== -1) { // document ended + rows = rows.filter(e => /^\d{10}/m.test(stripSpaces(e))); // filter non-table rows + resolve(rows.map(e => {return {color: e.split('$')[3], number: stripSpaces(e.split('$')[0])}; })); + } + }); + }); +} + +function stripSpaces(s) { + return s ? s.replace(/ /g,'') : ''; +} + +function trim(s) { + return s.replace(/(^\s+|\s+$)/gm, ''); +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d3b646e..93fdea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, "requires": { "@babel/highlight": "^7.8.3" } @@ -213,6 +214,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.8.3.tgz", "integrity": "sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==", + "dev": true, "requires": { "chalk": "^2.0.0", "esutils": "^2.0.2", @@ -393,12 +395,14 @@ "@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", - "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==" + "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", + "dev": true }, "@szmarczak/http-timer": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "dev": true, "requires": { "defer-to-connect": "^1.0.1" } @@ -406,12 +410,14 @@ "@types/bcrypt": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-3.0.0.tgz", - "integrity": "sha512-nohgNyv+1ViVcubKBh0+XiNJ3dO8nYu///9aJ4cgSqv70gBL+94SNy/iC2NLzKPT2Zt/QavrOkBVbZRLZmw6NQ==" + "integrity": "sha512-nohgNyv+1ViVcubKBh0+XiNJ3dO8nYu///9aJ4cgSqv70gBL+94SNy/iC2NLzKPT2Zt/QavrOkBVbZRLZmw6NQ==", + "dev": true }, "@types/body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "dev": true, "requires": { "@types/connect": "*", "@types/node": "*" @@ -421,6 +427,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.2.tgz", "integrity": "sha512-+uWmsejEHfmSjyyM/LkrP0orfE2m5Mx9Xel4tXNeqi1ldK5XMQcDsFkBmLDtuyKUbxj2jGDo0H240fbCRJZo7Q==", + "dev": true, "requires": { "@types/node": "*" } @@ -428,12 +435,14 @@ "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", - "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", + "dev": true }, "@types/connect": { "version": "3.4.33", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.33.tgz", "integrity": "sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==", + "dev": true, "requires": { "@types/node": "*" } @@ -442,6 +451,7 @@ "version": "4.17.5", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.5.tgz", "integrity": "sha512-578YH5Lt88AKoADy0b2jQGwJtrBxezXtVe/MBqWXKZpqx91SnC0pVkVCcxcytz3lWW+cHBYDi3Ysh0WXc+rAYw==", + "dev": true, "requires": { "@types/node": "*", "@types/range-parser": "*" @@ -456,17 +466,20 @@ "@types/mime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", - "integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==" + "integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==", + "dev": true }, "@types/mocha": { "version": "5.2.7", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz", - "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==" + "integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==", + "dev": true }, "@types/mongodb": { "version": "3.5.10", "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.5.10.tgz", "integrity": "sha512-6NkJNfFdFa/njBvN/9eAfq78bWUnapkdR3JbWGGpd7U71PjgKweA4Tlag8psi2mqm973vBYVTD1oc1u0lzRcig==", + "dev": true, "requires": { "@types/bson": "*", "@types/node": "*" @@ -476,6 +489,7 @@ "version": "5.7.12", "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.7.12.tgz", "integrity": "sha512-yzLJk3cdSwuMXaIacUCWUb8m960YcgnID7S4ZPOOgzT39aSC46670TuunN+ajDio7OUcGG4mGg8eOGs2Z6VmrA==", + "dev": true, "requires": { "@types/mongodb": "*", "@types/node": "*" @@ -484,22 +498,26 @@ "@types/node": { "version": "13.1.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.6.tgz", - "integrity": "sha512-Jg1F+bmxcpENHP23sVKkNuU3uaxPnsBMW0cLjleiikFKomJQbsn0Cqk2yDvQArqzZN6ABfBkZ0To7pQ8sLdWDg==" + "integrity": "sha512-Jg1F+bmxcpENHP23sVKkNuU3uaxPnsBMW0cLjleiikFKomJQbsn0Cqk2yDvQArqzZN6ABfBkZ0To7pQ8sLdWDg==", + "dev": true }, "@types/qs": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.1.tgz", - "integrity": "sha512-lhbQXx9HKZAPgBkISrBcmAcMpZsmpe/Cd/hY7LGZS5OfkySUBItnPZHgQPssWYUET8elF+yCFBbP1Q0RZPTdaw==" + "integrity": "sha512-lhbQXx9HKZAPgBkISrBcmAcMpZsmpe/Cd/hY7LGZS5OfkySUBItnPZHgQPssWYUET8elF+yCFBbP1Q0RZPTdaw==", + "dev": true }, "@types/range-parser": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", - "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==", + "dev": true }, "@types/serve-static": { "version": "1.13.3", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz", "integrity": "sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==", + "dev": true, "requires": { "@types/express-serve-static-core": "*", "@types/mime": "*" @@ -508,7 +526,8 @@ "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true }, "accepts": { "version": "1.3.7", @@ -533,6 +552,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz", "integrity": "sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw==", + "dev": true, "requires": { "string-width": "^3.0.0" }, @@ -540,12 +560,14 @@ "ansi-regex": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true }, "string-width": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, "requires": { "emoji-regex": "^7.0.1", "is-fullwidth-code-point": "^2.0.0", @@ -556,6 +578,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, "requires": { "ansi-regex": "^4.1.0" } @@ -578,6 +601,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, "requires": { "color-convert": "^1.9.0" } @@ -586,6 +610,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", + "dev": true, "requires": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -636,7 +661,8 @@ "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true }, "basic-auth": { "version": "2.0.1", @@ -654,7 +680,8 @@ "binary-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz", - "integrity": "sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==" + "integrity": "sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==", + "dev": true }, "bluebird": { "version": "3.5.1", @@ -676,6 +703,16 @@ "qs": "6.7.0", "raw-body": "2.4.0", "type-is": "~1.6.17" + }, + "dependencies": { + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + } } }, "bowser": { @@ -687,6 +724,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz", "integrity": "sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ==", + "dev": true, "requires": { "ansi-align": "^3.0.0", "camelcase": "^5.3.1", @@ -701,12 +739,14 @@ "ansi-regex": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true }, "ansi-styles": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, "requires": { "@types/color-name": "^1.1.1", "color-convert": "^2.0.1" @@ -716,6 +756,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -725,6 +766,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "requires": { "color-name": "~1.1.4" } @@ -732,27 +774,32 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true }, "string-width": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -763,6 +810,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, "requires": { "ansi-regex": "^5.0.0" } @@ -771,6 +819,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -781,6 +830,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -790,6 +840,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, "requires": { "fill-range": "^7.0.1" } @@ -808,7 +859,8 @@ "builtin-modules": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", - "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=" + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", + "dev": true }, "bytes": { "version": "3.1.0", @@ -819,6 +871,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", + "dev": true, "requires": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", @@ -833,6 +886,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz", "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==", + "dev": true, "requires": { "pump": "^3.0.0" } @@ -840,7 +894,8 @@ "lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true } } }, @@ -864,7 +919,8 @@ "camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true }, "camelize": { "version": "1.0.0", @@ -885,6 +941,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -895,6 +952,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.1.tgz", "integrity": "sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg==", + "dev": true, "requires": { "anymatch": "~3.1.1", "braces": "~3.0.2", @@ -909,7 +967,8 @@ "ci-info": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==" + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true }, "clean-stack": { "version": "2.2.0", @@ -920,7 +979,8 @@ "cli-boxes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.0.tgz", - "integrity": "sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w==" + "integrity": "sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w==", + "dev": true }, "cliui": { "version": "5.0.0", @@ -965,6 +1025,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", + "dev": true, "requires": { "mimic-response": "^1.0.0" } @@ -973,6 +1034,7 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, "requires": { "color-name": "1.1.3" } @@ -980,7 +1042,8 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true }, "combined-stream": { "version": "1.0.8", @@ -1040,12 +1103,14 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true }, "configstore": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", + "dev": true, "requires": { "dot-prop": "^5.2.0", "graceful-fs": "^4.1.2", @@ -1134,7 +1199,18 @@ "crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", - "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==" + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true + }, + "csv-parser": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-2.3.3.tgz", + "integrity": "sha512-czcyxc4/3Tt63w0oiK1zsnRgRD4PkqWaRSJ6eef63xC0f+5LVLuGdSYEcJwGp2euPgRHx+jmlH2Lb49anb1CGQ==", + "dev": true, + "requires": { + "minimist": "^1.2.0", + "through2": "^3.0.1" + } }, "dasherize": { "version": "2.0.0", @@ -1159,6 +1235,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", + "dev": true, "requires": { "mimic-response": "^1.0.0" } @@ -1166,7 +1243,8 @@ "deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true }, "default-require-extensions": { "version": "3.0.0", @@ -1180,7 +1258,8 @@ "defer-to-connect": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", - "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==" + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", + "dev": true }, "define-properties": { "version": "1.1.3", @@ -1210,7 +1289,8 @@ "diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==" + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true }, "dns-prefetch-control": { "version": "0.2.0", @@ -1226,6 +1306,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz", "integrity": "sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==", + "dev": true, "requires": { "is-obj": "^2.0.0" } @@ -1233,7 +1314,8 @@ "duplexer3": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", - "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", + "dev": true }, "ee-first": { "version": "1.1.1", @@ -1243,7 +1325,8 @@ "emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true }, "encodeurl": { "version": "1.0.2", @@ -1254,6 +1337,7 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, "requires": { "once": "^1.4.0" } @@ -1297,7 +1381,8 @@ "escape-goat": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", - "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==" + "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==", + "dev": true }, "escape-html": { "version": "1.0.3", @@ -1307,7 +1392,8 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true }, "esprima": { "version": "4.0.1", @@ -1317,7 +1403,8 @@ "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true }, "etag": { "version": "1.8.1", @@ -1381,6 +1468,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, "requires": { "to-regex-range": "^5.0.1" } @@ -1497,12 +1585,14 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true }, "fsevents": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.2.tgz", "integrity": "sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA==", + "dev": true, "optional": true }, "function-bind": { @@ -1533,6 +1623,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, "requires": { "pump": "^3.0.0" } @@ -1541,6 +1632,7 @@ "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -1554,6 +1646,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.0.tgz", "integrity": "sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==", + "dev": true, "requires": { "is-glob": "^4.0.1" } @@ -1562,6 +1655,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-2.0.1.tgz", "integrity": "sha512-5HqUqdhkEovj2Of/ms3IeS/EekcO54ytHRLV4PEY2rhRwrHXLQjeVEES0Lhka0xwNDtGYn58wyC4s5+MHsOO6A==", + "dev": true, "requires": { "ini": "^1.3.5" } @@ -1576,6 +1670,7 @@ "version": "9.6.0", "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", + "dev": true, "requires": { "@sindresorhus/is": "^0.14.0", "@szmarczak/http-timer": "^1.1.2", @@ -1593,7 +1688,8 @@ "graceful-fs": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", - "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==" + "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", + "dev": true }, "growl": { "version": "1.10.5", @@ -1613,7 +1709,8 @@ "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true }, "has-symbols": { "version": "1.0.1", @@ -1624,7 +1721,8 @@ "has-yarn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", - "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==" + "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==", + "dev": true }, "hasha": { "version": "5.2.0", @@ -1721,7 +1819,8 @@ "http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", - "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" + "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", + "dev": true }, "http-errors": { "version": "1.7.2", @@ -1736,9 +1835,10 @@ } }, "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.0.tgz", + "integrity": "sha512-43ZpGYZ9QtuutX5l6WC1DSO8ane9N+Ct5qPLF2OV7vM9abM69gnAbVkh66ibaZd3aOGkoP1ZmringlKhLBkw2Q==", + "dev": true, "requires": { "safer-buffer": ">= 2.1.2 < 3" } @@ -1751,17 +1851,26 @@ "ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=" + "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=", + "dev": true + }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=", + "dev": true }, "import-lazy": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", - "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=" + "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=", + "dev": true }, "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true }, "indent-string": { "version": "4.0.0", @@ -1773,6 +1882,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -1786,7 +1896,8 @@ "ini": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", - "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "dev": true }, "ipaddr.js": { "version": "1.9.0", @@ -1797,6 +1908,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "requires": { "binary-extensions": "^2.0.0" } @@ -1817,6 +1929,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, "requires": { "ci-info": "^2.0.0" } @@ -1830,17 +1943,20 @@ "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true }, "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true }, "is-glob": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, "requires": { "is-extglob": "^2.1.1" } @@ -1849,6 +1965,7 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz", "integrity": "sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g==", + "dev": true, "requires": { "global-dirs": "^2.0.1", "is-path-inside": "^3.0.1" @@ -1857,22 +1974,26 @@ "is-npm": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz", - "integrity": "sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig==" + "integrity": "sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig==", + "dev": true }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true }, "is-obj": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true }, "is-path-inside": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz", - "integrity": "sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==" + "integrity": "sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==", + "dev": true }, "is-regex": { "version": "1.0.5", @@ -1901,7 +2022,8 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true }, "is-windows": { "version": "1.0.2", @@ -1912,7 +2034,8 @@ "is-yarn-global": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz", - "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==" + "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==", + "dev": true }, "isarray": { "version": "1.0.0", @@ -2051,7 +2174,8 @@ "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, "js-yaml": { "version": "3.13.1", @@ -2071,7 +2195,8 @@ "json-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", - "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=" + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", + "dev": true }, "json-schema": { "version": "0.2.5", @@ -2087,6 +2212,18 @@ "minimist": "^1.2.5" } }, + "jszip": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.5.0.tgz", + "integrity": "sha512-WRtu7TPCmYePR1nazfrtuF216cIVon/3GWOvHS9QR5bIwSbnxtdpma6un3jyGGNhHsKCSzn5Ypk+EkDRvTGiFA==", + "dev": true, + "requires": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "set-immediate-shim": "~1.0.1" + } + }, "kareem": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.1.tgz", @@ -2096,6 +2233,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "dev": true, "requires": { "json-buffer": "3.0.0" } @@ -2104,10 +2242,20 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz", "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==", + "dev": true, "requires": { "package-json": "^6.3.0" } }, + "lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "requires": { + "immediate": "~3.0.5" + } + }, "locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", @@ -2151,12 +2299,14 @@ "lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", - "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==" + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "dev": true }, "make-dir": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.2.tgz", "integrity": "sha512-rYKABKutXa6vXTXhoV18cBE7PaewPXHe/Bdq4v+ZLMhxbWApkFFplT0LcbMW+6BbjnQXzZ/sAvSE/JdguApG5w==", + "dev": true, "requires": { "semver": "^6.0.0" }, @@ -2164,7 +2314,8 @@ "semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true } } }, @@ -2210,12 +2361,14 @@ "mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==" + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, "requires": { "brace-expansion": "^1.1.7" } @@ -2223,12 +2376,14 @@ "minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true }, "mkdirp": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, "requires": { "minimist": "^1.2.5" } @@ -2447,6 +2602,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.3.tgz", "integrity": "sha512-lLQLPS90Lqwc99IHe0U94rDgvjo+G9I4uEIxRG3evSLROcqQ9hwc0AxlSHKS4T1JW/IMj/7N5mthiN58NL/5kw==", + "dev": true, "requires": { "chokidar": "^3.2.2", "debug": "^3.2.6", @@ -2464,6 +2620,7 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, "requires": { "ms": "^2.1.1" } @@ -2471,7 +2628,8 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true } } }, @@ -2479,6 +2637,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=", + "dev": true, "requires": { "abbrev": "1" } @@ -2486,12 +2645,14 @@ "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true }, "normalize-url": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", - "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==" + "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==", + "dev": true }, "nyc": { "version": "15.0.1", @@ -2734,6 +2895,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, "requires": { "wrappy": "1" } @@ -2743,10 +2905,17 @@ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-1.3.5.tgz", "integrity": "sha512-11oi4zYorsgvg5yBarZplAqbpev5HkuVNPlZaPTknPDzAynq+lnJdXAmruGWP0s+dNYZS7bjM+xrTpJw7184Fg==" }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, "p-cancelable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", - "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==" + "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", + "dev": true }, "p-limit": { "version": "2.3.0", @@ -2797,6 +2966,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", "integrity": "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==", + "dev": true, "requires": { "got": "^9.6.0", "registry-auth-token": "^4.0.0", @@ -2807,10 +2977,17 @@ "semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true } } }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2825,7 +3002,8 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true }, "path-key": { "version": "3.1.1", @@ -2836,17 +3014,76 @@ "path-parse": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true }, "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" }, + "pdf2json": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pdf2json/-/pdf2json-1.2.0.tgz", + "integrity": "sha512-Z/m+OFOe13Nn2SHQNSINZ6Mh2b8t2bK3whL3L6b5Av1wqDvotYvpMg1Zi8aEPV37jF0jG0yQ83c8XuuNbIsn6Q==", + "dev": true, + "requires": { + "async": "^3.2.0", + "lodash": "^4.17.13", + "optimist": "^0.6.1", + "xmldom": "^0.3.0" + }, + "dependencies": { + "async": { + "version": "3.2.0", + "bundled": true, + "dev": true + }, + "lodash": { + "version": "4.17.15", + "bundled": true, + "dev": true + }, + "minimist": { + "version": "0.0.10", + "bundled": true, + "dev": true + }, + "optimist": { + "version": "0.6.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + } + }, + "wordwrap": { + "version": "0.0.3", + "bundled": true, + "dev": true + }, + "xmldom": { + "version": "0.3.0", + "bundled": true, + "dev": true + } + } + }, + "pdfreader": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/pdfreader/-/pdfreader-1.0.7.tgz", + "integrity": "sha512-3hX/PpA/MQV2uvSiR2CH7isuyZXqYPoA6IXOxHd7hw9qS6Lz9RKYKu+iU369+OgkJKe/SHpxwEbgoHBV4L/76w==", + "dev": true, + "requires": { + "pdf2json": "^1.1.8" + } + }, "picomatch": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.1.tgz", - "integrity": "sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA==" + "integrity": "sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA==", + "dev": true }, "pkg-dir": { "version": "4.2.0", @@ -2901,7 +3138,8 @@ "prepend-http": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", - "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=" + "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", + "dev": true }, "process-nextick-args": { "version": "2.0.1", @@ -2930,12 +3168,14 @@ "pstree.remy": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.7.tgz", - "integrity": "sha512-xsMgrUwRpuGskEzBFkH8NmTimbZ5PcPup0LA8JJkHIm2IMUbQcpo3yeLNWVrufEYjh8YwtSVh0xz6UeWc5Oh5A==" + "integrity": "sha512-xsMgrUwRpuGskEzBFkH8NmTimbZ5PcPup0LA8JJkHIm2IMUbQcpo3yeLNWVrufEYjh8YwtSVh0xz6UeWc5Oh5A==", + "dev": true }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, "requires": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -2945,6 +3185,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.0.1.tgz", "integrity": "sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA==", + "dev": true, "requires": { "escape-goat": "^2.0.0" } @@ -2968,12 +3209,23 @@ "http-errors": "1.7.2", "iconv-lite": "0.4.24", "unpipe": "1.0.0" + }, + "dependencies": { + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + } } }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, "requires": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -3000,6 +3252,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.3.0.tgz", "integrity": "sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ==", + "dev": true, "requires": { "picomatch": "^2.0.7" } @@ -3018,6 +3271,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.1.1.tgz", "integrity": "sha512-9bKS7nTl9+/A1s7tnPeGrUpRcVY+LUh7bfFgzpndALdPfXQBfQV77rQVtqgUV3ti4vc/Ik81Ex8UJDWDQ12zQA==", + "dev": true, "requires": { "rc": "^1.2.8" } @@ -3026,6 +3280,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz", "integrity": "sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==", + "dev": true, "requires": { "rc": "^1.2.8" } @@ -3064,6 +3319,7 @@ "version": "1.14.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.14.2.tgz", "integrity": "sha512-EjlOBLBO1kxsUxsKjLt7TAECyKW6fOh1VRkykQkKGzcBbjjPIxBqGh0jf7GJ3k/f5mxMqW3htMD3WdTUVtW8HQ==", + "dev": true, "requires": { "path-parse": "^1.0.6" } @@ -3077,6 +3333,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", + "dev": true, "requires": { "lowercase-keys": "^1.0.0" } @@ -3109,6 +3366,28 @@ "sparse-bitfield": "^3.0.3" } }, + "selenium-webdriver": { + "version": "4.0.0-alpha.7", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.0.0-alpha.7.tgz", + "integrity": "sha512-D4qnTsyTr91jT8f7MfN+OwY0IlU5+5FmlO5xlgRUV6hDEV8JyYx2NerdTEqDDkNq7RZDYc4VoPALk8l578RBHw==", + "dev": true, + "requires": { + "jszip": "^3.2.2", + "rimraf": "^2.7.1", + "tmp": "0.0.30" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -3118,6 +3397,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==", + "dev": true, "requires": { "semver": "^6.3.0" }, @@ -3125,7 +3405,8 @@ "semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true } } }, @@ -3173,6 +3454,12 @@ "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true }, + "set-immediate-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", + "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=", + "dev": true + }, "setprototypeof": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", @@ -3255,7 +3542,8 @@ "signal-exit": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", - "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true }, "sliced": { "version": "1.0.1", @@ -3391,7 +3679,8 @@ "strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true }, "superagent": { "version": "3.8.3", @@ -3442,6 +3731,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, "requires": { "has-flag": "^3.0.0" } @@ -3462,7 +3752,8 @@ "term-size": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.0.tgz", - "integrity": "sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw==" + "integrity": "sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw==", + "dev": true }, "test-exclude": { "version": "6.0.0", @@ -3475,6 +3766,24 @@ "minimatch": "^3.0.4" } }, + "through2": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.1.tgz", + "integrity": "sha512-M96dvTalPT3YbYLaKaCuwu+j06D/8Jfib0o/PxbVt6Amhv3dUAtW6rTV1jPgJSBG83I/e04Y6xkVdVhSRhi0ww==", + "dev": true, + "requires": { + "readable-stream": "2 || 3" + } + }, + "tmp": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.30.tgz", + "integrity": "sha1-ckGdSovn1s51FI/YsyTlk6cRwu0=", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.1" + } + }, "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -3484,12 +3793,14 @@ "to-readable-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", - "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==" + "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", + "dev": true }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "requires": { "is-number": "^7.0.0" } @@ -3503,6 +3814,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, "requires": { "nopt": "~1.0.10" } @@ -3510,12 +3822,14 @@ "tslib": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", - "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" + "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==", + "dev": true }, "tslint": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.20.1.tgz", "integrity": "sha512-EcMxhzCFt8k+/UP5r8waCf/lzmeSyVlqxqMEDQE7rWYiQky8KpIBz1JAoYXfROHrPZ1XXd43q8yQnULOLiBRQg==", + "dev": true, "requires": { "@babel/code-frame": "^7.0.0", "builtin-modules": "^1.1.1", @@ -3536,6 +3850,7 @@ "version": "2.29.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", + "dev": true, "requires": { "tslib": "^1.8.1" } @@ -3543,7 +3858,8 @@ "type-fest": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==" + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true }, "type-is": { "version": "1.6.18", @@ -3558,6 +3874,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, "requires": { "is-typedarray": "^1.0.0" } @@ -3565,12 +3882,14 @@ "typescript": { "version": "3.7.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.7.4.tgz", - "integrity": "sha512-A25xv5XCtarLwXpcDNZzCGvW2D1S3/bACratYBx2sax8PefsFhlYmkQicKHvpYflFS8if4zne5zT5kpJ7pzuvw==" + "integrity": "sha512-A25xv5XCtarLwXpcDNZzCGvW2D1S3/bACratYBx2sax8PefsFhlYmkQicKHvpYflFS8if4zne5zT5kpJ7pzuvw==", + "dev": true }, "undefsafe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.3.tgz", "integrity": "sha512-nrXZwwXrD/T/JXeygJqdCO6NZZ1L66HrxM/Z7mIq2oPanoN0F1nLx3lwJMu6AwJY69hdixaFQOuoYsMjE5/C2A==", + "dev": true, "requires": { "debug": "^2.2.0" } @@ -3584,6 +3903,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, "requires": { "crypto-random-string": "^2.0.0" } @@ -3597,6 +3917,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-4.1.0.tgz", "integrity": "sha512-w3doE1qtI0/ZmgeoDoARmI5fjDoT93IfKgEGqm26dGUOh8oNpaSTsGNdYRN/SjOuo10jcJGwkEL3mroKzktkew==", + "dev": true, "requires": { "boxen": "^4.2.0", "chalk": "^3.0.0", @@ -3617,6 +3938,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, "requires": { "@types/color-name": "^1.1.1", "color-convert": "^2.0.1" @@ -3626,6 +3948,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3635,6 +3958,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "requires": { "color-name": "~1.1.4" } @@ -3642,17 +3966,20 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true }, "supports-color": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -3663,6 +3990,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", + "dev": true, "requires": { "prepend-http": "^2.0.0" } @@ -3722,6 +4050,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dev": true, "requires": { "string-width": "^4.0.0" }, @@ -3729,22 +4058,26 @@ "ansi-regex": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true }, "string-width": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -3755,6 +4088,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, "requires": { "ansi-regex": "^5.0.0" } @@ -3803,12 +4137,14 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true }, "write-file-atomic": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, "requires": { "imurmurhash": "^0.1.4", "is-typedarray": "^1.0.0", @@ -3824,7 +4160,8 @@ "xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", - "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==" + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "dev": true }, "y18n": { "version": "4.0.0", diff --git a/package.json b/package.json index 777e274..e5ca620 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "start": "node index.js", "dev": "nodemon -e ts,yaml --exec \"tsc && node dist/index.js || exit 1\"", "loadDev": "node dist/test/loadDev.js", - "coverage": "tsc && nyc --reporter=html --reporter=text mocha dist/**/**.spec.js --timeout 5000" + "coverage": "tsc && nyc --reporter=html --reporter=text mocha dist/**/**.spec.js --timeout 5000", + "import": "node data_import/import.js" }, "keywords": [], "author": "", @@ -45,9 +46,13 @@ "@types/node": "^13.1.6", "@types/qs": "^6.9.1", "@types/serve-static": "^1.13.3", + "csv-parser": "^2.3.3", + "iconv-lite": "^0.6.0", "mocha": "^7.1.2", "nodemon": "^2.0.3", "nyc": "^15.0.1", + "pdfreader": "^1.0.7", + "selenium-webdriver": "^4.0.0-alpha.7", "should": "^13.2.3", "supertest": "^4.0.2", "tslint": "^5.20.1", diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 9a483a4..4af6d55 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -10,6 +10,7 @@ import mongoose from 'mongoose'; // TODO: generate output for ML in format DPT -> data, implement filtering, field selection // TODO: generate csv // TODO: write script for data import +// TODO: allowed types: tension rod, part, granulate, other describe('/sample', () => { let server; @@ -18,6 +19,7 @@ describe('/sample', () => { afterEach(done => TestHelper.afterEach(server, done)); after(done => TestHelper.after(done)); + // TODO: sort, added date filter, has measurements/condition filter describe('GET /samples', () => { it('returns all samples', done => { TestHelper.request(server, done, { @@ -30,7 +32,7 @@ describe('/sample', () => { const json = require('../test/db.json'); should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.validated).length); should(res.body).matchEach(sample => { - should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); should(sample).have.property('_id').be.type('string'); should(sample).have.property('number').be.type('string'); should(sample).have.property('type').be.type('string'); @@ -41,6 +43,7 @@ describe('/sample', () => { should(sample).have.property('material_id').be.type('string'); should(sample).have.property('note_id'); should(sample).have.property('user_id').be.type('string'); + should(sample).have.property('added').be.type('string'); }); done(); }); @@ -56,7 +59,7 @@ describe('/sample', () => { const json = require('../test/db.json'); should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.validated).length); should(res.body).matchEach(sample => { - should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); should(sample).have.property('_id').be.type('string'); should(sample).have.property('number').be.type('string'); should(sample).have.property('type').be.type('string'); @@ -67,6 +70,7 @@ describe('/sample', () => { should(sample).have.property('material_id').be.type('string'); should(sample).have.property('note_id'); should(sample).have.property('user_id').be.type('string'); + should(sample).have.property('added').be.type('string'); }); done(); }); @@ -82,7 +86,7 @@ describe('/sample', () => { const json = require('../test/db.json'); should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.new).length); should(res.body).matchEach(sample => { - should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); should(sample).have.property('_id').be.type('string'); should(sample).have.property('number').be.type('string'); should(sample).have.property('type').be.type('string'); @@ -92,10 +96,114 @@ describe('/sample', () => { should(sample).have.property('material_id').be.type('string'); should(sample).have.property('note_id'); should(sample).have.property('user_id').be.type('string'); + should(sample).have.property('added').be.type('string'); }); done(); }); }); + it('uses the given page size', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&page-size=3', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(3); + done(); + }); + }); + it('returns results starting after last-id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&last-id=400000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('_id', '400000000000000000000003'); + should(res.body[1]).have.property('_id', '400000000000000000000004'); + done(); + }); + }); + it('returns the right page number', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&to-page=2&page-size=2', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('_id', '400000000000000000000006'); + done(); + }); + }); + it('works with negative page numbers', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&to-page=-1&page-size=2&last-id=400000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('_id', '400000000000000000000001'); + should(res.body[1]).have.property('_id', '400000000000000000000002'); + done(); + }); + }); + it('returns an empty array for a page number out of range', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&to-page=100&page-size=2', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(0); + should(res.body).be.eql([]); + done(); + }); + }); + it('returns an empty array for a page number out of negative range', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&to-page=-100&page-size=3&last-id=400000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(0); + should(res.body).be.eql([]); + done(); + }); + }); + it('rejects a negative page size', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?page-size=-3', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"page-size" must be larger than or equal to 1'} + }); + }); + it('rejects an invalid last-id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?last-id=40000000000h000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"last-id" with value "40000000000h000000000002" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects a to-page without page-size', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?to-page=3', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"to-page" missing required peer "page-size"'} + }); + }); it('rejects an invalid state name', done => { TestHelper.request(server, done, { method: 'get', @@ -127,7 +235,7 @@ describe('/sample', () => { let asyncCounter = res.body.length; should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.new).length); should(res.body).matchEach(sample => { - should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); should(sample).have.property('_id').be.type('string'); should(sample).have.property('number').be.type('string'); should(sample).have.property('type').be.type('string'); @@ -140,6 +248,7 @@ describe('/sample', () => { should(sample).have.property('material_id').be.type('string'); should(sample).have.property('note_id'); should(sample).have.property('user_id').be.type('string'); + should(sample).have.property('added').be.type('string'); SampleModel.findById(sample._id).lean().exec((err, data) => { should(data).have.property('status',globals.status.new); if (--asyncCounter === 0) { @@ -161,7 +270,7 @@ describe('/sample', () => { let asyncCounter = res.body.length; should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === -1).length); should(res.body).matchEach(sample => { - should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); should(sample).have.property('_id').be.type('string'); should(sample).have.property('number').be.type('string'); should(sample).have.property('type').be.type('string'); @@ -174,6 +283,7 @@ describe('/sample', () => { should(sample).have.property('material_id').be.type('string'); should(sample).have.property('note_id'); should(sample).have.property('user_id').be.type('string'); + should(sample).have.property('added').be.type('string'); SampleModel.findById(sample._id).lean().exec((err, data) => { should(data).have.property('status',globals.status.deleted); if (--asyncCounter === 0) { @@ -269,7 +379,7 @@ describe('/sample', () => { }); }); - describe('PUT /sample/{id}', () => { + describe('PUT /sample/{id}', () => { // TODO: fix tests, work on /samples it('returns the right sample', done => { TestHelper.request(server, done, { method: 'put', @@ -277,7 +387,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 200, req: {}, - res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} + res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'} }); }); it('keeps unchanged properties', done => { @@ -289,7 +399,7 @@ describe('/sample', () => { req: {type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', notes: {}} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}); + should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'}); SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { if (err) return done (err); should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v'); @@ -316,7 +426,7 @@ describe('/sample', () => { req: {type: 'granulate'} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}); + should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'}); SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { if (err) return done (err); should(data).have.property('status',globals.status.validated); @@ -333,7 +443,7 @@ describe('/sample', () => { req: {condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}); + should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'}); SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { if (err) return done (err); should(data).have.property('status',globals.status.validated); @@ -350,7 +460,7 @@ describe('/sample', () => { req: {notes: {comment: 'Stoff gesperrt', sample_references: []}} }).end((err, res) => { if (err) return done(err); - should(res.body).be.eql({_id: '400000000000000000000002', number: '21', type: 'granulate', color: 'natural', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', note_id: '500000000000000000000001', user_id: '000000000000000000000002'}); + should(res.body).be.eql({_id: '400000000000000000000002', number: '21', type: 'granulate', color: 'natural', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', note_id: '500000000000000000000001', user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'}); SampleModel.findById('400000000000000000000002').lean().exec((err, data: any) => { if (err) return done (err); should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v'); @@ -627,7 +737,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 200, req: {condition: {}}, - res: {_id: '400000000000000000000006', number: 'Rng36', type: 'granulate', color: 'black', batch: '', condition: {}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} + res: {_id: '400000000000000000000006', number: 'Rng36', type: 'granulate', color: 'black', batch: '', condition: {}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'} }); }); it('rejects an old version of a condition template', done => { @@ -647,7 +757,7 @@ describe('/sample', () => { auth: {basic: 'admin'}, httpStatus: 200, req: {condition: {p1: 36, condition_template: '200000000000000000000004'}}, - res: {_id: '400000000000000000000004', number: '32', type: 'granulate', color: 'black', batch: '1653000308', condition: {p1: 36, condition_template: '200000000000000000000004'}, material_id: '100000000000000000000005', note_id: '500000000000000000000003', user_id: '000000000000000000000003'} + res: {_id: '400000000000000000000004', number: '32', type: 'granulate', color: 'black', batch: '1653000308', condition: {p1: 36, condition_template: '200000000000000000000004'}, material_id: '100000000000000000000005', note_id: '500000000000000000000003', user_id: '000000000000000000000003', added: '2004-01-10T13:37:04.000Z'} }); }); it('rejects an changing back to an empty condition', done => { @@ -694,7 +804,7 @@ describe('/sample', () => { auth: {basic: 'admin'}, httpStatus: 200, req: {}, - res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {condition_template: '200000000000000000000001', material: 'copper', weeks: 3}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} + res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {condition_template: '200000000000000000000001', material: 'copper', weeks: 3}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'} }); }); it('rejects requests from a read user', done => { @@ -1085,7 +1195,7 @@ describe('/sample', () => { req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} }).end((err, res) => { if (err) return done (err); - should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); should(res.body).have.property('_id').be.type('string'); should(res.body).have.property('number', 'Rng37'); should(res.body).have.property('color', 'black'); @@ -1095,6 +1205,8 @@ describe('/sample', () => { should(res.body).have.property('material_id', '100000000000000000000001'); should(res.body).have.property('note_id').be.type('string'); should(res.body).have.property('user_id', '000000000000000000000002'); + should(res.body).have.property('added').be.type('string'); + should(new Date().getTime() - new Date(res.body.added).getTime()).be.below(1000); done(); }); }); @@ -1198,7 +1310,7 @@ describe('/sample', () => { req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} }).end((err, res) => { if (err) return done (err); - should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); should(res.body).have.property('_id').be.type('string'); should(res.body).have.property('number', 'Fe1'); should(res.body).have.property('color', 'black'); @@ -1207,6 +1319,8 @@ describe('/sample', () => { should(res.body).have.property('material_id', '100000000000000000000001'); should(res.body).have.property('note_id').be.type('string'); should(res.body).have.property('user_id', '000000000000000000000004'); + should(res.body).have.property('added').be.type('string'); + should(new Date().getTime() - new Date(res.body.added).getTime()).be.below(1500); done(); }); }); @@ -1219,7 +1333,7 @@ describe('/sample', () => { req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}} }).end((err, res) => { if (err) return done (err); - should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); should(res.body).have.property('_id').be.type('string'); should(res.body).have.property('number', 'Rng37'); should(res.body).have.property('color', 'black'); @@ -1229,6 +1343,8 @@ describe('/sample', () => { should(res.body).have.property('material_id', '100000000000000000000001'); should(res.body).have.property('note_id').be.type('string'); should(res.body).have.property('user_id', '000000000000000000000002'); + should(res.body).have.property('added').be.type('string'); + should(new Date().getTime() - new Date(res.body.added).getTime()).be.below(1000); done(); }); }); @@ -1271,7 +1387,7 @@ describe('/sample', () => { req: {number: 'Rng34', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, }).end((err, res) => { if (err) return done (err); - should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id'); + should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added'); should(res.body).have.property('_id').be.type('string'); should(res.body).have.property('number', 'Rng34'); should(res.body).have.property('color', 'black'); @@ -1281,6 +1397,8 @@ describe('/sample', () => { should(res.body).have.property('material_id', '100000000000000000000001'); should(res.body).have.property('note_id').be.type('string'); should(res.body).have.property('user_id', '000000000000000000000003'); + should(res.body).have.property('added').be.type('string'); + should(new Date().getTime() - new Date(res.body.added).getTime()).be.below(1000); done(); }); }); diff --git a/src/routes/sample.ts b/src/routes/sample.ts index d28e725..24f33cf 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -25,22 +25,43 @@ router.get('/samples', (req, res, next) => { const {error, value: filters} = SampleValidate.query(req.query); if (error) return res400(error, res); - let conditions; - + let status; if (filters.hasOwnProperty('status')) { if(filters.status === 'all') { - conditions = {$or: [{status: globals.status.validated}, {status: globals.status.new}]} + status = {$or: [{status: globals.status.validated}, {status: globals.status.new}]} } else { - conditions = {status: globals.status[filters.status]}; + status = {status: globals.status[filters.status]}; } } else { // default - conditions = {status: globals.status.validated}; + status = {status: globals.status.validated}; + } + const query = SampleModel.find(status); + + if (filters['page-size']) { + query.limit(filters['page-size']); } - SampleModel.find(conditions).lean().exec((err, data) => { + if (filters['last-id']) { + if (filters['to-page'] && filters['to-page'] < 0) { + query.lte('_id', mongoose.Types.ObjectId(filters['last-id'])); // TODO: consider sorting + query.sort({_id: -1}); + } + else { + query.gt('_id', mongoose.Types.ObjectId(filters['last-id'])); // TODO: consider sorting + } + } + + if (filters['to-page']) { + query.skip(Math.abs(filters['to-page']) * filters['page-size']); // TODO: check order for negative numbers + } + + query.lean().exec((err, data) => { if (err) return next(err); + if (filters['to-page'] && filters['to-page'] < 0) { + data.reverse(); + } res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors }) }); diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 1da56da..f1f17f9 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -44,7 +44,11 @@ export default class SampleValidate { Joi.date() ) ) - }) + }), + + added: Joi.date() + .iso() + .min('1970-01-01T00:00:00.000Z') }; static input (data, param) { // validate input, set param to 'new' to make all attributes required @@ -85,6 +89,7 @@ export default class SampleValidate { } static output (data, param = 'refs') { // validate output and strip unwanted properties, returns null if not valid + data.added = data._id.getTimestamp(); data = IdValidate.stringify(data); let joiObject; if (param === 'refs') { @@ -97,7 +102,8 @@ export default class SampleValidate { condition: this.sample.condition, material_id: IdValidate.get(), note_id: IdValidate.get().allow(null), - user_id: IdValidate.get() + user_id: IdValidate.get(), + added: this.sample.added }; } else if(param === 'details') { @@ -123,7 +129,10 @@ export default class SampleValidate { static query (data) { return Joi.object({ - status: Joi.string().valid('validated', 'new', 'all') - }).validate(data); + status: Joi.string().valid('validated', 'new', 'all'), + 'last-id': IdValidate.get(), + 'to-page': Joi.number().integer(), + 'page-size': Joi.number().integer().min(1) + }).with('to-page', 'page-size').validate(data); } } \ No newline at end of file From 4dad680edf63d7a6ae954f99a030773badffe317 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 25 Jun 2020 11:59:36 +0200 Subject: [PATCH 69/83] changed last-id behaviour to from-id --- api/sample.yaml | 6 +++--- src/routes/sample.spec.ts | 22 +++++++++++----------- src/routes/sample.ts | 9 +++++---- src/routes/validate/sample.ts | 2 +- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/api/sample.yaml b/api/sample.yaml index 4ebb61f..b37702d 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -12,14 +12,14 @@ schema: type: string example: all - - name: last-id - description: last id of current page, if not given the results are displayed from start + - name: from-id + description: first id of the requested page, if not given the results are displayed from start in: query schema: type: string example: 5ea0450ed851c30a90e70894 - name: to-page - description: relative change of pages, use negative values to get back, defaults to 0 (if last-id is given, the sample after is the first of the result, so the next page is selected automatically), works only together with page-size + description: relative change of pages, use negative values to get back, defaults to 0, works only together with page-size in: query schema: type: string diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 4af6d55..bd3cb79 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -113,16 +113,16 @@ describe('/sample', () => { done(); }); }); - it('returns results starting after last-id', done => { + it('returns results starting from first-id', done => { TestHelper.request(server, done, { method: 'get', - url: '/samples?status=all&last-id=400000000000000000000002', + url: '/samples?status=all&from-id=400000000000000000000002', auth: {basic: 'janedoe'}, httpStatus: 200 }).end((err, res) => { if (err) return done(err); - should(res.body[0]).have.property('_id', '400000000000000000000003'); - should(res.body[1]).have.property('_id', '400000000000000000000004'); + should(res.body[0]).have.property('_id', '400000000000000000000002'); + should(res.body[1]).have.property('_id', '400000000000000000000003'); done(); }); }); @@ -141,13 +141,13 @@ describe('/sample', () => { it('works with negative page numbers', done => { TestHelper.request(server, done, { method: 'get', - url: '/samples?status=all&to-page=-1&page-size=2&last-id=400000000000000000000004', + url: '/samples?status=all&to-page=-1&page-size=2&from-id=400000000000000000000004', auth: {basic: 'janedoe'}, httpStatus: 200 }).end((err, res) => { if (err) return done(err); - should(res.body[0]).have.property('_id', '400000000000000000000001'); - should(res.body[1]).have.property('_id', '400000000000000000000002'); + should(res.body[0]).have.property('_id', '400000000000000000000002'); + should(res.body[1]).have.property('_id', '400000000000000000000003'); done(); }); }); @@ -167,7 +167,7 @@ describe('/sample', () => { it('returns an empty array for a page number out of negative range', done => { TestHelper.request(server, done, { method: 'get', - url: '/samples?status=all&to-page=-100&page-size=3&last-id=400000000000000000000004', + url: '/samples?status=all&to-page=-100&page-size=3&from-id=400000000000000000000004', auth: {basic: 'janedoe'}, httpStatus: 200 }).end((err, res) => { @@ -186,13 +186,13 @@ describe('/sample', () => { res: {status: 'Invalid body format', details: '"page-size" must be larger than or equal to 1'} }); }); - it('rejects an invalid last-id', done => { + it('rejects an invalid from-id', done => { TestHelper.request(server, done, { method: 'get', - url: '/samples?last-id=40000000000h000000000002', + url: '/samples?from-id=40000000000h000000000002', auth: {basic: 'janedoe'}, httpStatus: 400, - res: {status: 'Invalid body format', details: '"last-id" with value "40000000000h000000000002" fails to match the required pattern: /[0-9a-f]{24}/'} + res: {status: 'Invalid body format', details: '"from-id" with value "40000000000h000000000002" fails to match the required pattern: /[0-9a-f]{24}/'} }); }); it('rejects a to-page without page-size', done => { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 24f33cf..156725b 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -43,18 +43,18 @@ router.get('/samples', (req, res, next) => { query.limit(filters['page-size']); } - if (filters['last-id']) { + if (filters['from-id']) { if (filters['to-page'] && filters['to-page'] < 0) { - query.lte('_id', mongoose.Types.ObjectId(filters['last-id'])); // TODO: consider sorting + query.lt('_id', mongoose.Types.ObjectId(filters['from-id'])); // TODO: consider sorting query.sort({_id: -1}); } else { - query.gt('_id', mongoose.Types.ObjectId(filters['last-id'])); // TODO: consider sorting + query.gte('_id', mongoose.Types.ObjectId(filters['from-id'])); // TODO: consider sorting } } if (filters['to-page']) { - query.skip(Math.abs(filters['to-page']) * filters['page-size']); // TODO: check order for negative numbers + query.skip(Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size']); // TODO: check order for negative numbers } query.lean().exec((err, data) => { @@ -62,6 +62,7 @@ router.get('/samples', (req, res, next) => { if (filters['to-page'] && filters['to-page'] < 0) { data.reverse(); } + console.log(data); res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors }) }); diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index f1f17f9..4b10a7c 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -130,7 +130,7 @@ export default class SampleValidate { static query (data) { return Joi.object({ status: Joi.string().valid('validated', 'new', 'all'), - 'last-id': IdValidate.get(), + 'from-id': IdValidate.get(), 'to-page': Joi.number().integer(), 'page-size': Joi.number().integer().min(1) }).with('to-page', 'page-size').validate(data); From 49f7a475b714303de23ace51142bc29835c82888 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 25 Jun 2020 14:29:54 +0200 Subject: [PATCH 70/83] added /samples/count --- api/sample.yaml | 19 +++++++++++++++++++ src/routes/sample.spec.ts | 36 ++++++++++++++++++++++++++++++++++++ src/routes/sample.ts | 10 +++++++++- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/api/sample.yaml b/api/sample.yaml index b37702d..bb5d9be 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -71,6 +71,25 @@ 500: $ref: 'api.yaml#/components/responses/500' +/samples/count: + get: + summary: total number of samples + description: 'Auth: all, levels: read, write, maintain, dev, admin' + tags: + - /sample + responses: + 200: + description: sample count + content: + application/json: + schema: + properties: + count: + type: number + example: 864 + 500: + $ref: 'api.yaml#/components/responses/500' + /sample/{id}: parameters: - $ref: 'api.yaml#/components/parameters/Id' diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index bd3cb79..bdda00e 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -318,6 +318,42 @@ describe('/sample', () => { }); }); + describe('GET /samples/count', () => { + it('returns the correct number of samples', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/count', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body.count).be.eql(json.collections.samples.length); + done(); + }); + }); + it('works with an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/count', + auth: {key: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body.count).be.eql(json.collections.samples.length); + done(); + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/count', + httpStatus: 401 + }); + }); + }); + describe('GET /sample/{id}', () => { it('returns the right sample', done => { TestHelper.request(server, done, { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 156725b..19ec993 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -62,7 +62,6 @@ router.get('/samples', (req, res, next) => { if (filters['to-page'] && filters['to-page'] < 0) { data.reverse(); } - console.log(data); res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors }) }); @@ -76,6 +75,15 @@ router.get('/samples/:state(new|deleted)', (req, res, next) => { }); }); +router.get('/samples/count', (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + SampleModel.estimatedDocumentCount((err, data) => { + if (err) return next(err); + res.json({count: data}); + }); +}); + router.get('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; From 43413001e9c8c569b4f0b77885fed67201a015c8 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 26 Jun 2020 09:38:28 +0200 Subject: [PATCH 71/83] sorting for direct sample properties added --- api/sample.yaml | 6 ++++ src/routes/sample.spec.ts | 53 ++++++++++++++++++++++++++++++++++ src/routes/sample.ts | 54 ++++++++++++++++++++++++++--------- src/routes/validate/sample.ts | 3 +- 4 files changed, 101 insertions(+), 15 deletions(-) diff --git a/api/sample.yaml b/api/sample.yaml index bb5d9be..5f07b78 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -30,6 +30,12 @@ schema: type: string example: 30 + - name: sort + description: sorting of results, in format 'key-asc/desc' + in: query + schema: + type: string + example: color-asc responses: 200: description: samples overview diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index bdda00e..5a32356 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -177,6 +177,59 @@ describe('/sample', () => { done(); }); }); + it('sorts the samples ascending', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&sort=color-asc', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('color', 'black'); + should(res.body[res.body.length - 1]).have.property('color', 'natural'); + done(); + }); + }); + it('sorts the samples descending', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&sort=number-desc', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('number', 'Rng36'); + should(res.body[1]).have.property('number', '33'); + should(res.body[res.body.length - 1]).have.property('number', '1'); + done(); + }); + }); + it('sorts the samples correctly in combination with paging', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&sort=color-asc&page-size=2&from-id=400000000000000000000006', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('_id', '400000000000000000000006'); + should(res.body[1]).have.property('_id', '400000000000000000000002'); + done(); + }); + }); + it('sorts the samples correctly in combination with going pages backward', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&sort=color-desc&page-size=2&from-id=400000000000000000000004&to-page=-1', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('_id', '400000000000000000000002'); + should(res.body[1]).have.property('_id', '400000000000000000000006'); + done(); + }); + }); it('rejects a negative page size', done => { TestHelper.request(server, done, { method: 'get', diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 19ec993..7bc1b67 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -19,7 +19,7 @@ import db from '../db'; const router = express.Router(); -router.get('/samples', (req, res, next) => { +router.get('/samples', async (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; const {error, value: filters} = SampleValidate.query(req.query); @@ -37,29 +37,55 @@ router.get('/samples', (req, res, next) => { else { // default status = {status: globals.status.validated}; } - const query = SampleModel.find(status); + + + const sort = []; + let paging = {} + + // sorting + filters.sort = filters.sort.split('-'); + filters.sort[0] = filters.sort[0] === 'added' ? '_id' : filters.sort[0]; // route added sorting criteria to _id + filters.sort[1] = filters.sort[1] === 'desc' ? -1 : 1; + + if (!filters['to-page']) { // set to-page default + filters['to-page'] = 0; + } + + if (filters['from-id']) { // from-id specified + const fromSample = SampleValidate.output(await SampleModel.findById(filters['from-id']).lean().exec().catch(err => {next(err);})); + if (fromSample instanceof Error) return; + + if ((filters['to-page'] === 0 && filters.sort[1] === 1) || (filters.sort[1] * filters['to-page'] > 0)) { // asc + paging = {$or: [{[filters.sort[0]]: {$gt: fromSample[filters.sort[0]]}}, {$and: [{[filters.sort[0]]: fromSample[filters.sort[0]]}, {_id: {$gte: mongoose.Types.ObjectId(filters['from-id'])}}]}]}; + sort.push([filters.sort[0], 1]); + sort.push(['_id', 1]); + } + else { + paging = {$or: [{[filters.sort[0]]: {$lt: fromSample[filters.sort[0]]}}, {$and: [{[filters.sort[0]]: fromSample[filters.sort[0]]}, {_id: {$lte: mongoose.Types.ObjectId(filters['from-id'])}}]}]}; + sort.push([filters.sort[0], -1]); + sort.push(['_id', -1]); + } + } + else { // sort from beginning + sort.push([filters.sort[0], filters.sort[1]]); // set _id as secondary sort + sort.push(['_id', filters.sort[1]]); // set _id as secondary sort + } + + const query = SampleModel.find({$and: [status, paging]}); if (filters['page-size']) { query.limit(filters['page-size']); } - if (filters['from-id']) { - if (filters['to-page'] && filters['to-page'] < 0) { - query.lt('_id', mongoose.Types.ObjectId(filters['from-id'])); // TODO: consider sorting - query.sort({_id: -1}); - } - else { - query.gte('_id', mongoose.Types.ObjectId(filters['from-id'])); // TODO: consider sorting - } + if (filters['to-page']) { + query.skip(Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size'] + Number(filters['to-page'] < 0)); } - if (filters['to-page']) { - query.skip(Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size']); // TODO: check order for negative numbers - } + query.sort(sort); query.lean().exec((err, data) => { if (err) return next(err); - if (filters['to-page'] && filters['to-page'] < 0) { + if (filters['to-page'] < 0) { data.reverse(); } res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 4b10a7c..7a706c2 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -132,7 +132,8 @@ export default class SampleValidate { status: Joi.string().valid('validated', 'new', 'all'), 'from-id': IdValidate.get(), 'to-page': Joi.number().integer(), - 'page-size': Joi.number().integer().min(1) + 'page-size': Joi.number().integer().min(1), + sort: Joi.string().pattern(/^(_id|color|number|type|batch|added)-(asc|desc)$/m).default('_id-asc') // TODO: material keys }).with('to-page', 'page-size').validate(data); } } \ No newline at end of file From 8aa051f0bdb35c85209c274742ab31b9d64345eb Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 26 Jun 2020 15:23:29 +0200 Subject: [PATCH 72/83] switched to aggregation, included material sort keys --- src/routes/sample.spec.ts | 14 +++++++++ src/routes/sample.ts | 54 +++++++++++++++++++---------------- src/routes/validate/sample.ts | 2 +- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 5a32356..0aa1c1f 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -230,6 +230,20 @@ describe('/sample', () => { done(); }); }); + it('sorts the samples correctly for material keys', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&sort=material.name-desc', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body[0]).have.property('_id', '400000000000000000000002'); + should(res.body[1]).have.property('_id', '400000000000000000000006'); + should(res.body[2]).have.property('_id', '400000000000000000000001'); + done(); + }); + }); it('rejects a negative page size', done => { TestHelper.request(server, done, { method: 'get', diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 7bc1b67..4a04fc3 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -25,65 +25,69 @@ router.get('/samples', async (req, res, next) => { const {error, value: filters} = SampleValidate.query(req.query); if (error) return res400(error, res); - let status; + const query = []; + query.push({$match: {$and: []}}); if (filters.hasOwnProperty('status')) { if(filters.status === 'all') { - status = {$or: [{status: globals.status.validated}, {status: globals.status.new}]} + query[0].$match.$and.push({$or: [{status: globals.status.validated}, {status: globals.status.new}]}); } else { - status = {status: globals.status[filters.status]}; + query[0].$match.$and.push({status: globals.status[filters.status]}); } } else { // default - status = {status: globals.status.validated}; + query[0].$match.$and.push({status: globals.status.validated}); } - - const sort = []; - let paging = {} - // sorting filters.sort = filters.sort.split('-'); filters.sort[0] = filters.sort[0] === 'added' ? '_id' : filters.sort[0]; // route added sorting criteria to _id filters.sort[1] = filters.sort[1] === 'desc' ? -1 : 1; - if (!filters['to-page']) { // set to-page default filters['to-page'] = 0; } + if (filters.sort[0].indexOf('material.') >= 0) { // need to populate materials, material supplier and group + query.push( + {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, + {$set: {material: { $arrayElemAt: ['$material', 0]}}}, + {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, + {$set: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}}, + {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, + {$set: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} + ); + } + if (filters['from-id']) { // from-id specified - const fromSample = SampleValidate.output(await SampleModel.findById(filters['from-id']).lean().exec().catch(err => {next(err);})); + const fromSample = await SampleModel.findById(filters['from-id']).lean().exec().catch(err => {next(err);}); if (fromSample instanceof Error) return; if ((filters['to-page'] === 0 && filters.sort[1] === 1) || (filters.sort[1] * filters['to-page'] > 0)) { // asc - paging = {$or: [{[filters.sort[0]]: {$gt: fromSample[filters.sort[0]]}}, {$and: [{[filters.sort[0]]: fromSample[filters.sort[0]]}, {_id: {$gte: mongoose.Types.ObjectId(filters['from-id'])}}]}]}; - sort.push([filters.sort[0], 1]); - sort.push(['_id', 1]); + query[0].$match.$and.push({$or: [{[filters.sort[0]]: {$gt: fromSample[filters.sort[0]]}}, {$and: [{[filters.sort[0]]: fromSample[filters.sort[0]]}, {_id: {$gte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}); + query.push({$sort: {[filters.sort[0]]: 1, _id: 1}}); } else { - paging = {$or: [{[filters.sort[0]]: {$lt: fromSample[filters.sort[0]]}}, {$and: [{[filters.sort[0]]: fromSample[filters.sort[0]]}, {_id: {$lte: mongoose.Types.ObjectId(filters['from-id'])}}]}]}; - sort.push([filters.sort[0], -1]); - sort.push(['_id', -1]); + query[0].$match.$and.push({$or: [{[filters.sort[0]]: {$lt: fromSample[filters.sort[0]]}}, {$and: [{[filters.sort[0]]: fromSample[filters.sort[0]]}, {_id: {$lte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}); + query.push({$sort: {[filters.sort[0]]: -1, _id: -1}}); } } else { // sort from beginning - sort.push([filters.sort[0], filters.sort[1]]); // set _id as secondary sort - sort.push(['_id', filters.sort[1]]); // set _id as secondary sort + query.push({$sort: {[filters.sort[0]]: filters.sort[1], '_id': filters.sort[1]}}); // set _id as secondary sort } - const query = SampleModel.find({$and: [status, paging]}); - - if (filters['page-size']) { - query.limit(filters['page-size']); + if (filters.sort[0].indexOf('material.') >= 0) { // unpopulate materials again + query.push({$unset: 'material'}); } if (filters['to-page']) { - query.skip(Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size'] + Number(filters['to-page'] < 0)); + query.push({$skip: Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size'] + Number(filters['to-page'] < 0)}) // number to skip, if going back pages, one page has to be skipped less but on sample more } - query.sort(sort); + if (filters['page-size']) { + query.push({$limit: filters['page-size']}); + } - query.lean().exec((err, data) => { + SampleModel.aggregate(query).exec((err, data) => { if (err) return next(err); if (filters['to-page'] < 0) { data.reverse(); diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 7a706c2..b55b847 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -133,7 +133,7 @@ export default class SampleValidate { 'from-id': IdValidate.get(), 'to-page': Joi.number().integer(), 'page-size': Joi.number().integer().min(1), - sort: Joi.string().pattern(/^(_id|color|number|type|batch|added)-(asc|desc)$/m).default('_id-asc') // TODO: material keys + sort: Joi.string().pattern(/^(_id|color|number|type|batch|added|material\.name|material\.supplier|material\.group|material\.mineral|material\.glass_fiber|material\.carbon_fiber)-(asc|desc)$/m).default('_id-asc') // TODO: material keys }).with('to-page', 'page-size').validate(data); } } \ No newline at end of file From e5cc661928f7b844f631c5b228513835329710d8 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 29 Jun 2020 12:27:39 +0200 Subject: [PATCH 73/83] base for csv export --- api/sample.yaml | 6 ++++++ package-lock.json | 22 ++++++++++++++++++++++ package.json | 1 + src/helpers/authorize.ts | 4 +++- src/helpers/csv.ts | 7 +++++++ src/index.ts | 3 +-- src/routes/sample.spec.ts | 15 +++++++++++++++ src/routes/sample.ts | 17 +++++++++++++++-- src/routes/validate/sample.ts | 3 ++- src/test/helper.ts | 26 +++++++++++++++----------- 10 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 src/helpers/csv.ts diff --git a/api/sample.yaml b/api/sample.yaml index 5f07b78..c6e59a1 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -36,6 +36,12 @@ schema: type: string example: color-asc + - name: csv + description: output as csv + in: query + schema: + type: boolean + example: false responses: 200: description: samples overview diff --git a/package-lock.json b/package-lock.json index 93fdea0..5478eef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2203,6 +2203,23 @@ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.5.tgz", "integrity": "sha512-gWJOWYFrhQ8j7pVm0EM8Slr+EPVq1Phf6lvzvD/WCeqkrx/f2xBI0xOsRRS9xCn3I4vKtP519dvs3TP09r24wQ==" }, + "json2csv": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-5.0.1.tgz", + "integrity": "sha512-QFMifUX1y8W2tKi2TwZpnzf2rHdZvzdmgZUMEMDF46F90f4a9mUeWfx/qg4kzXSZYJYc3cWA5O+eLXk5lj9g8g==", + "requires": { + "commander": "^5.0.0", + "jsonparse": "^1.3.1", + "lodash.get": "^4.4.2" + }, + "dependencies": { + "commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==" + } + } + }, "json5": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", @@ -2212,6 +2229,11 @@ "minimist": "^1.2.5" } }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=" + }, "jszip": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.5.0.tgz", diff --git a/package.json b/package.json index e5ca620..4b04218 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "express": "^4.17.1", "helmet": "^3.22.0", "json-schema": "^0.2.5", + "json2csv": "^5.0.1", "lodash": "^4.17.15", "mongo-sanitize": "^1.1.0", "mongoose": "^5.8.7", diff --git a/src/helpers/authorize.ts b/src/helpers/authorize.ts index 71a42c2..03d344b 100644 --- a/src/helpers/authorize.ts +++ b/src/helpers/authorize.ts @@ -89,7 +89,9 @@ function key (req, next): any { // checks API key and returns changed user obje if (err) return next(err); if (data.length === 1) { // one user found resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString(), location: data[0].location}); - delete req.query.key; // delete query parameter to avoid interference with later validation + if (!/^\/api/m.test(req.url)){ + delete req.query.key; // delete query parameter to avoid interference with later validation + } } else { resolve(null); diff --git a/src/helpers/csv.ts b/src/helpers/csv.ts new file mode 100644 index 0000000..dbeb213 --- /dev/null +++ b/src/helpers/csv.ts @@ -0,0 +1,7 @@ +import {parseAsync} from 'json2csv'; + +export default function csv(input: object, fields: string[], f: (err, data) => void) { + parseAsync(input) + .then(csv => f(null, csv)) + .catch(err => f(err, null)); +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 9af77cf..97a080e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,9 +48,8 @@ app.use(require('./helpers/authorize')); // handle authentication // redirect /api routes for Angular proxy in development if (process.env.NODE_ENV !== 'production') { - app.use('/api/:url([^]+)', (req, res) => { + app.use('/api/:url([^]+)', (req, ignore) => { req.url = '/' + req.params.url; - app.handle(req, res); }); } diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 0aa1c1f..e7f6cfb 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -244,6 +244,21 @@ describe('/sample', () => { done(); }); }); + it('returns a correct csv file if specified', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&page-size=2&csv=true', + contentType: /text\/csv/, + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.text).be.eql('"_id","number","type","color","batch","condition","material_id","note_id","user_id","added"\r\n' + + '"400000000000000000000001","1","granulate","black","","{""material"":""copper"",""weeks"":3,""condition_template"":""200000000000000000000001""}","100000000000000000000004",,"000000000000000000000002","2004-01-10T13:37:04.000Z"\r\n' + + '"400000000000000000000002","21","granulate","natural","1560237365","{""material"":""copper"",""weeks"":3,""condition_template"":""200000000000000000000001""}","100000000000000000000001","500000000000000000000001","000000000000000000000002","2004-01-10T13:37:04.000Z"'); + done(); + }); + }); it('rejects a negative page size', done => { TestHelper.request(server, done, { method: 'get', diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 4a04fc3..82a4eea 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -15,6 +15,7 @@ import ConditionTemplateModel from '../models/condition_template'; import ParametersValidate from './validate/parameters'; import globals from '../globals'; import db from '../db'; +import csv from '../helpers/csv'; const router = express.Router(); @@ -54,7 +55,10 @@ router.get('/samples', async (req, res, next) => { {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, {$set: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}}, {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, - {$set: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} + {$set: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}}, + {$set: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]} + } + } ); } @@ -92,7 +96,16 @@ router.get('/samples', async (req, res, next) => { if (filters['to-page'] < 0) { data.reverse(); } - res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors + if (filters.csv) { // output as csv // TODO: csv example in OAS + csv(_.compact(data.map(e => SampleValidate.output(e))), ['_id', 'number'], (err, data) => { + if (err) return next(err); + res.set('Content-Type', 'text/csv'); + res.send(data); + }); + } + else { + res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors + } }) }); diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index b55b847..d6c77a2 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -133,7 +133,8 @@ export default class SampleValidate { 'from-id': IdValidate.get(), 'to-page': Joi.number().integer(), 'page-size': Joi.number().integer().min(1), - sort: Joi.string().pattern(/^(_id|color|number|type|batch|added|material\.name|material\.supplier|material\.group|material\.mineral|material\.glass_fiber|material\.carbon_fiber)-(asc|desc)$/m).default('_id-asc') // TODO: material keys + sort: Joi.string().pattern(/^(_id|color|number|type|batch|added|material\.name|material\.supplier|material\.group|material\.mineral|material\.glass_fiber|material\.carbon_fiber|material\.number)-(asc|desc)$/m).default('_id-asc'), + csv: Joi.boolean().default(false) }).with('to-page', 'page-size').validate(data); } } \ No newline at end of file diff --git a/src/test/helper.ts b/src/test/helper.ts index e1e8eec..44085f7 100644 --- a/src/test/helper.ts +++ b/src/test/helper.ts @@ -38,15 +38,7 @@ export default class TestHelper { return server } - static afterEach (server, done) { - server.close(done); - } - - static after(done) { - db.disconnect(done); - } - - static request (server, done, options) { // options in form: {method, url, auth: {key/basic: 'name' or 'key'/{name, pass}}, httpStatus, req, res, default (set to false if you want to dismiss default .end handling)} + static request (server, done, options) { // options in form: {method, url, contentType, auth: {key/basic: 'name' or 'key'/{name, pass}}, httpStatus, req, res, default (set to false if you want to dismiss default .end handling)} let st = supertest(server); if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('key')) { // resolve API key options.url += '?key=' + (this.auth.hasOwnProperty(options.auth.key)? this.auth[options.auth.key].key : options.auth.key); @@ -79,8 +71,12 @@ export default class TestHelper { st = st.auth(options.auth.basic.name, options.auth.basic.pass) } } - st = st.expect('Content-type', /json/) - .expect(options.httpStatus); + if (options.hasOwnProperty('contentType')) { + st = st.expect('Content-type', options.contentType).expect(options.httpStatus); + } + else { + st = st.expect('Content-type', /json/).expect(options.httpStatus); + } if (options.hasOwnProperty('res')) { // evaluate result return st.end((err, res) => { if (err) return done (err); @@ -128,4 +124,12 @@ export default class TestHelper { return st; } } + + static afterEach (server, done) { + server.close(done); + } + + static after(done) { + db.disconnect(done); + } } \ No newline at end of file From 52eb828beadba70b490595ef377ffee10fd49258 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 29 Jun 2020 15:50:24 +0200 Subject: [PATCH 74/83] first implementation of fields --- api/sample.yaml | 8 ++++++- src/db.ts | 2 +- src/index.ts | 3 ++- src/routes/sample.spec.ts | 29 +++++++++++++++++++++++- src/routes/sample.ts | 42 +++++++++++++++++++++-------------- src/routes/validate/sample.ts | 39 ++++++++++++++++++++++++++++---- 6 files changed, 98 insertions(+), 25 deletions(-) diff --git a/api/sample.yaml b/api/sample.yaml index c6e59a1..f527513 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -42,9 +42,15 @@ schema: type: boolean example: false + - name: fields + description: the fields to include in the output as array, defaults to ['_id','number','type','batch','material_id','color','condition','note_id','user_id','added'] + in: query + schema: + type: string + example: '&fields[]=number&fields[]=batch' responses: 200: - description: samples overview + description: samples overview (if the csv parameter is set, this is in CSV instead of JSON format) content: application/json: schema: diff --git a/src/db.ts b/src/db.ts index 60dadf9..cb11af5 100644 --- a/src/db.ts +++ b/src/db.ts @@ -3,7 +3,7 @@ import cfenv from 'cfenv'; import _ from 'lodash'; import ChangelogModel from './models/changelog'; -// mongoose.set('debug', true); // enable mongoose debug +mongoose.set('debug', true); // enable mongoose debug // database urls, prod db url is retrieved automatically const TESTING_URL = 'mongodb://localhost/dfopdb_test'; diff --git a/src/index.ts b/src/index.ts index 97a080e..9af77cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,8 +48,9 @@ app.use(require('./helpers/authorize')); // handle authentication // redirect /api routes for Angular proxy in development if (process.env.NODE_ENV !== 'production') { - app.use('/api/:url([^]+)', (req, ignore) => { + app.use('/api/:url([^]+)', (req, res) => { req.url = '/' + req.params.url; + app.handle(req, res); }); } diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index e7f6cfb..29e35b7 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -259,6 +259,33 @@ describe('/sample', () => { done(); }); }); + it('returns only the fields specified', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&page-size=1&fields[]=number&fields[]=condition&fields[]=color&fields[]=material.name&fields[]=material.mineral', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: [{number: '1', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, color: 'black', material: {name: 'Schulamid 66 GF 25 H', mineral: 0}}] + }); + }); + it('rejects an invalid fields parameter', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&page-size=1&fields=number', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"fields" must be an array'} + }); + }); + it('rejects an unknown field name', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&page-size=1&fields[]=xx', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"fields[0]" must be one of [_id, color, number, type, batch, added, material.name, material.supplier, material.group, material.mineral, material.glass_fiber, material.carbon_fiber, material.number, condition, material_id, note_id, user_id, material._id, material.numbers]'} + }); + }); it('rejects a negative page size', done => { TestHelper.request(server, done, { method: 'get', @@ -302,7 +329,7 @@ describe('/sample', () => { httpStatus: 401 }); }); - }); + }); // TODO: measurement fields describe('GET /samples/{state}', () => { it('returns all new samples', done => { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 82a4eea..f356211 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -48,19 +48,17 @@ router.get('/samples', async (req, res, next) => { filters['to-page'] = 0; } - if (filters.sort[0].indexOf('material.') >= 0) { // need to populate materials, material supplier and group - query.push( - {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, - {$set: {material: { $arrayElemAt: ['$material', 0]}}}, - {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, - {$set: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}}, - {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, - {$set: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}}, - {$set: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]} - } + query.push( + {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, + {$set: {material: { $arrayElemAt: ['$material', 0]}}}, + {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, + {$set: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}}, + {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, + {$set: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}}, + {$set: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]} } - ); - } + } + ); if (filters['from-id']) { // from-id specified const fromSample = await SampleModel.findById(filters['from-id']).lean().exec().catch(err => {next(err);}); @@ -79,9 +77,9 @@ router.get('/samples', async (req, res, next) => { query.push({$sort: {[filters.sort[0]]: filters.sort[1], '_id': filters.sort[1]}}); // set _id as secondary sort } - if (filters.sort[0].indexOf('material.') >= 0) { // unpopulate materials again - query.push({$unset: 'material'}); - } + // if (filters.sort[0].indexOf('material.') >= 0) { // unpopulate materials again + // query.push({$unset: 'material'}); + // } if (filters['to-page']) { query.push({$skip: Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size'] + Number(filters['to-page'] < 0)}) // number to skip, if going back pages, one page has to be skipped less but on sample more @@ -90,6 +88,16 @@ router.get('/samples', async (req, res, next) => { if (filters['page-size']) { query.push({$limit: filters['page-size']}); } + console.log(filters.fields); + const projection = filters.fields.reduce((s, e) => {s[e] = true; return s; }, {}); + if (filters.fields.indexOf('added') >= 0) { // add added date + projection.added = {$toDate: '$_id'}; + } + if (!(filters.fields.indexOf('_id') >= 0)) { // disable _id explicitly + console.log('disable id'); + projection._id = false; + } + query.push({$project: projection}); SampleModel.aggregate(query).exec((err, data) => { if (err) return next(err); @@ -97,14 +105,14 @@ router.get('/samples', async (req, res, next) => { data.reverse(); } if (filters.csv) { // output as csv // TODO: csv example in OAS - csv(_.compact(data.map(e => SampleValidate.output(e))), ['_id', 'number'], (err, data) => { + csv(_.compact(data.map(e => SampleValidate.output(e, 'refs'))), ['_id', 'number'], (err, data) => { if (err) return next(err); res.set('Content-Type', 'text/csv'); res.send(data); }); } else { - res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors + res.json(_.compact(data.map(e => SampleValidate.output(e, 'refs')))); // validate all and filter null values from validation errors } }) }); diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index d6c77a2..520f91b 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -51,6 +51,32 @@ export default class SampleValidate { .min('1970-01-01T00:00:00.000Z') }; + private static sortKeys = [ + '_id', + 'color', + 'number', + 'type', + 'batch', + 'added', + 'material.name', + 'material.supplier', + 'material.group', + 'material.mineral', + 'material.glass_fiber', + 'material.carbon_fiber', + 'material.number' + ]; + + private static fieldKeys = [ + ...SampleValidate.sortKeys, + 'condition', + 'material_id', + 'note_id', + 'user_id', + 'material._id', + 'material.numbers' + ]; + static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { return Joi.object({ @@ -88,8 +114,11 @@ export default class SampleValidate { } } - static output (data, param = 'refs') { // validate output and strip unwanted properties, returns null if not valid - data.added = data._id.getTimestamp(); + static output (data, param = 'refs+added') { // validate output and strip unwanted properties, returns null if not valid + if (param === 'refs+added') { + param = 'refs'; + data.added = data._id.getTimestamp(); + } data = IdValidate.stringify(data); let joiObject; if (param === 'refs') { @@ -101,6 +130,7 @@ export default class SampleValidate { batch: this.sample.batch, condition: this.sample.condition, material_id: IdValidate.get(), + material: MaterialValidate.outputV(), note_id: IdValidate.get().allow(null), user_id: IdValidate.get(), added: this.sample.added @@ -133,8 +163,9 @@ export default class SampleValidate { 'from-id': IdValidate.get(), 'to-page': Joi.number().integer(), 'page-size': Joi.number().integer().min(1), - sort: Joi.string().pattern(/^(_id|color|number|type|batch|added|material\.name|material\.supplier|material\.group|material\.mineral|material\.glass_fiber|material\.carbon_fiber|material\.number)-(asc|desc)$/m).default('_id-asc'), - csv: Joi.boolean().default(false) + sort: Joi.string().pattern(new RegExp('^(' + this.sortKeys.join('|').replace(/\./g, '\\.') + ')-(asc|desc)$', 'm')).default('_id-asc'), + csv: Joi.boolean().default(false), + fields: Joi.array().items(Joi.string().valid(...this.fieldKeys)).default(['_id','number','type','batch','material_id','color','condition','note_id','user_id','added']) }).with('to-page', 'page-size').validate(data); } } \ No newline at end of file From 8cf1c14d887811e48247f08239e7e6b20817fd93 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Tue, 30 Jun 2020 14:16:37 +0200 Subject: [PATCH 75/83] implementation of measurement fields --- api/sample.yaml | 8 +++-- data_import/import.js | 4 +-- src/helpers/csv.ts | 31 ++++++++++++++++++-- src/routes/sample.spec.ts | 55 +++++++++++++++++++++++++++++++---- src/routes/sample.ts | 46 ++++++++++++++++++++++------- src/routes/validate/sample.ts | 13 ++++++--- src/test/db.json | 14 +++++++++ 7 files changed, 145 insertions(+), 26 deletions(-) diff --git a/api/sample.yaml b/api/sample.yaml index f527513..91c57e0 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -42,12 +42,14 @@ schema: type: boolean example: false - - name: fields + - name: fields[] description: the fields to include in the output as array, defaults to ['_id','number','type','batch','material_id','color','condition','note_id','user_id','added'] in: query schema: - type: string - example: '&fields[]=number&fields[]=batch' + type: array + items: + type: string + example: ['number', 'batch'] responses: 200: description: samples overview (if the csv parameter is set, this is in CSV instead of JSON format) diff --git a/data_import/import.js b/data_import/import.js index 28614c8..a72ee0c 100644 --- a/data_import/import.js +++ b/data_import/import.js @@ -32,10 +32,10 @@ async function main() { await allSamples(); await saveSamples(); } - else if (0) { // DPT + else if (1) { // DPT await allDpts(); } - else if (1) { // KF/VZ + else if (0) { // KF/VZ await importCsv(); await allKfVz(); } diff --git a/src/helpers/csv.ts b/src/helpers/csv.ts index dbeb213..a307ca5 100644 --- a/src/helpers/csv.ts +++ b/src/helpers/csv.ts @@ -1,7 +1,34 @@ import {parseAsync} from 'json2csv'; -export default function csv(input: object, fields: string[], f: (err, data) => void) { - parseAsync(input) +export default function csv(input: any[], f: (err, data) => void) { + parseAsync(input.map(e => flatten(e))) .then(csv => f(null, csv)) .catch(err => f(err, null)); +} + +function flatten (data) { // flatten object: {a: {b: true}} -> {a.b: true} + const result = {}; + function recurse (cur, prop) { + if (Object(cur) !== cur || Object.keys(cur).length === 0) { + result[prop] = cur; + } + else if (Array.isArray(cur)) { + let l = 0; + for(let i = 0, l = cur.length; i < l; i++) + recurse(cur[i], prop + "[" + i + "]"); + if (l == 0) + result[prop] = []; + } + else { + let isEmpty = true; + for (let p in cur) { + isEmpty = false; + recurse(cur[p], prop ? prop+"."+p : p); + } + if (isEmpty && prop) + result[prop] = {}; + } + } + recurse(data, ''); + return result; } \ No newline at end of file diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 29e35b7..df7c242 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -244,6 +244,42 @@ describe('/sample', () => { done(); }); }); + it('adds the specified measurements', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=measurements.kf', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body.find(e => e.number === '1')).have.property('kf', {}); + should(res.body.find(e => e.number === 'Rng36')).have.property('kf', {'weight %': 0.5, 'standard deviation': null}); + done(); + }); + }); + it('multiplies the sample information for each spectrum', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=measurements.spectrum', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(2); + should(res.body[0]).have.property('spectrum', [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]); + should(res.body[1]).have.property('spectrum', [[3996.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]); + done(); + }); + }); + it('rejects unknown measurement names', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=measurements.xx', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: 'Measurement key not found'} + }); + }); it('returns a correct csv file if specified', done => { TestHelper.request(server, done, { method: 'get', @@ -253,9 +289,9 @@ describe('/sample', () => { httpStatus: 200 }).end((err, res) => { if (err) return done(err); - should(res.text).be.eql('"_id","number","type","color","batch","condition","material_id","note_id","user_id","added"\r\n' + - '"400000000000000000000001","1","granulate","black","","{""material"":""copper"",""weeks"":3,""condition_template"":""200000000000000000000001""}","100000000000000000000004",,"000000000000000000000002","2004-01-10T13:37:04.000Z"\r\n' + - '"400000000000000000000002","21","granulate","natural","1560237365","{""material"":""copper"",""weeks"":3,""condition_template"":""200000000000000000000001""}","100000000000000000000001","500000000000000000000001","000000000000000000000002","2004-01-10T13:37:04.000Z"'); + should(res.text).be.eql('"_id","number","type","color","batch","condition.material","condition.weeks","condition.condition_template","material_id","note_id","user_id","added"\r\n' + + '"400000000000000000000001","1","granulate","black","","copper",3,"200000000000000000000001","100000000000000000000004",,"000000000000000000000002","2004-01-10T13:37:04.000Z"\r\n' + + '"400000000000000000000002","21","granulate","natural","1560237365","copper",3,"200000000000000000000001","100000000000000000000001","500000000000000000000001","000000000000000000000002","2004-01-10T13:37:04.000Z"'); done(); }); }); @@ -268,6 +304,15 @@ describe('/sample', () => { res: [{number: '1', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, color: 'black', material: {name: 'Schulamid 66 GF 25 H', mineral: 0}}] }); }); + it('rejects a from-id not in the database', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?from-id=5ea0450ed851c30a90e70894&sort=color-asc', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: 'from-id not found'} + }); + }); it('rejects an invalid fields parameter', done => { TestHelper.request(server, done, { method: 'get', @@ -283,7 +328,7 @@ describe('/sample', () => { url: '/samples?status=all&page-size=1&fields[]=xx', auth: {basic: 'janedoe'}, httpStatus: 400, - res: {status: 'Invalid body format', details: '"fields[0]" must be one of [_id, color, number, type, batch, added, material.name, material.supplier, material.group, material.mineral, material.glass_fiber, material.carbon_fiber, material.number, condition, material_id, note_id, user_id, material._id, material.numbers]'} + res: {status: 'Invalid body format', details: '"fields[0]" with value "xx" fails to match the required pattern: /^(_id|color|number|type|batch|added|material\\.name|material\\.supplier|material\\.group|material\\.mineral|material\\.glass_fiber|material\\.carbon_fiber|material\\.number|condition|material_id|material|note_id|user_id|material\\._id|material\\.numbers|measurements\\..+)$/m'} }); }); it('rejects a negative page size', done => { @@ -329,7 +374,7 @@ describe('/sample', () => { httpStatus: 401 }); }); - }); // TODO: measurement fields + }); describe('GET /samples/{state}', () => { it('returns all new samples', done => { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index f356211..eef30f9 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -6,6 +6,7 @@ import NoteFieldValidate from './validate/note_field'; import res400 from './validate/res400'; import SampleModel from '../models/sample' import MeasurementModel from '../models/measurement'; +import MeasurementTemplateModel from '../models/measurement_template'; import MaterialModel from '../models/material'; import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; @@ -63,6 +64,9 @@ router.get('/samples', async (req, res, next) => { if (filters['from-id']) { // from-id specified const fromSample = await SampleModel.findById(filters['from-id']).lean().exec().catch(err => {next(err);}); if (fromSample instanceof Error) return; + if (!fromSample) { + return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'}); + } if ((filters['to-page'] === 0 && filters.sort[1] === 1) || (filters.sort[1] * filters['to-page'] > 0)) { // asc query[0].$match.$and.push({$or: [{[filters.sort[0]]: {$gt: fromSample[filters.sort[0]]}}, {$and: [{[filters.sort[0]]: fromSample[filters.sort[0]]}, {_id: {$gte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}); @@ -77,10 +81,6 @@ router.get('/samples', async (req, res, next) => { query.push({$sort: {[filters.sort[0]]: filters.sort[1], '_id': filters.sort[1]}}); // set _id as secondary sort } - // if (filters.sort[0].indexOf('material.') >= 0) { // unpopulate materials again - // query.push({$unset: 'material'}); - // } - if (filters['to-page']) { query.push({$skip: Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size'] + Number(filters['to-page'] < 0)}) // number to skip, if going back pages, one page has to be skipped less but on sample more } @@ -88,13 +88,38 @@ router.get('/samples', async (req, res, next) => { if (filters['page-size']) { query.push({$limit: filters['page-size']}); } - console.log(filters.fields); - const projection = filters.fields.reduce((s, e) => {s[e] = true; return s; }, {}); + + let measurementFields = []; + if (filters.fields.find(e => /measurements\./.test(e))) { // joining measurements is required + query.push({$lookup: {from: 'measurements', localField: '_id', foreignField: 'sample_id', as: 'measurements'}}); + measurementFields = filters.fields.filter(e => /measurements\./.test(e)).map(e => e.replace('measurements.', '')); + const measurementTemplates = await MeasurementTemplateModel.find({$or: measurementFields.map(e => {return {name: e}})}).lean().exec().catch(err => {next(err);}); + if (measurementTemplates instanceof Error) return; + if (measurementTemplates.length < measurementFields.length) { + return res.status(400).json({status: 'Invalid body format', details: 'Measurement key not found'}); + } + measurementTemplates.filter(e => e.name !== 'spectrum').forEach(template => { // TODO: hard coded dpt for special treatment, change later + query.push({$set: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values + vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}}, + in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']} + }}}}, {$set: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', {}]}}}); + }); + console.log(measurementFields); + if (measurementFields.find(e => e === 'spectrum')) { // TODO: remove hardcoded as well + query.push( + {$set: {spectrum: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', measurementTemplates.filter(e => e.name === 'spectrum')[0]._id]}}}}}, + {$set: {spectrum: '$spectrum.values.dpt'}}, + {$unwind: '$spectrum'} + ); + } + query.push({$unset: 'measurements'}); + } + + const projection = filters.fields.map(e => e.replace('measurements.', '')).reduce((s, e) => {s[e] = true; return s; }, {}); if (filters.fields.indexOf('added') >= 0) { // add added date projection.added = {$toDate: '$_id'}; } if (!(filters.fields.indexOf('_id') >= 0)) { // disable _id explicitly - console.log('disable id'); projection._id = false; } query.push({$project: projection}); @@ -104,15 +129,16 @@ router.get('/samples', async (req, res, next) => { if (filters['to-page'] < 0) { data.reverse(); } - if (filters.csv) { // output as csv // TODO: csv example in OAS - csv(_.compact(data.map(e => SampleValidate.output(e, 'refs'))), ['_id', 'number'], (err, data) => { + console.log(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields)))); + if (filters.csv) { // output as csv + csv(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields))), (err, data) => { if (err) return next(err); res.set('Content-Type', 'text/csv'); res.send(data); }); } else { - res.json(_.compact(data.map(e => SampleValidate.output(e, 'refs')))); // validate all and filter null values from validation errors + res.json(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields)))); // validate all and filter null values from validation errors } }) }); diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 520f91b..41435dd 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -71,10 +71,12 @@ export default class SampleValidate { ...SampleValidate.sortKeys, 'condition', 'material_id', + 'material', 'note_id', 'user_id', 'material._id', - 'material.numbers' + 'material.numbers', + 'measurements.*' ]; static input (data, param) { // validate input, set param to 'new' to make all attributes required @@ -114,7 +116,7 @@ export default class SampleValidate { } } - static output (data, param = 'refs+added') { // validate output and strip unwanted properties, returns null if not valid + static output (data, param = 'refs+added', additionalParams = []) { // validate output and strip unwanted properties, returns null if not valid if (param === 'refs+added') { param = 'refs'; data.added = data._id.getTimestamp(); @@ -130,7 +132,7 @@ export default class SampleValidate { batch: this.sample.batch, condition: this.sample.condition, material_id: IdValidate.get(), - material: MaterialValidate.outputV(), + material: MaterialValidate.outputV().append({number: Joi.string().max(128).allow('')}), note_id: IdValidate.get().allow(null), user_id: IdValidate.get(), added: this.sample.added @@ -153,6 +155,9 @@ export default class SampleValidate { else { return null; } + additionalParams.forEach(param => { + joiObject[param] = Joi.any(); + }); const {value, error} = Joi.object(joiObject).validate(data, {stripUnknown: true}); return error !== undefined? null : value; } @@ -165,7 +170,7 @@ export default class SampleValidate { 'page-size': Joi.number().integer().min(1), sort: Joi.string().pattern(new RegExp('^(' + this.sortKeys.join('|').replace(/\./g, '\\.') + ')-(asc|desc)$', 'm')).default('_id-asc'), csv: Joi.boolean().default(false), - fields: Joi.array().items(Joi.string().valid(...this.fieldKeys)).default(['_id','number','type','batch','material_id','color','condition','note_id','user_id','added']) + fields: Joi.array().items(Joi.string().pattern(new RegExp('^(' + this.fieldKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')$', 'm'))).default(['_id','number','type','batch','material_id','color','condition','note_id','user_id','added']) }).with('to-page', 'page-size').validate(data); } } \ No newline at end of file diff --git a/src/test/db.json b/src/test/db.json index ef26a63..aa68283 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -417,6 +417,20 @@ "status": 0, "measurement_template": {"$oid":"300000000000000000000002"}, "__v": 0 + }, + { + "_id": {"$oid":"800000000000000000000007"}, + "sample_id": {"$oid":"400000000000000000000001"}, + "values": { + "dpt": [ + [3996.12558,98.00555], + [3995.08519,98.03253], + [3993.04480,98.02657] + ] + }, + "status": 10, + "measurement_template": {"$oid":"300000000000000000000001"}, + "__v": 0 } ], "condition_templates": [ From e4bc5a77f1f4f1e071a10c0c7c70b63be88f735e Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 2 Jul 2020 12:18:01 +0200 Subject: [PATCH 76/83] restructured aggregation --- data_import/import.js | 1 + src/helpers/csv.ts | 8 +- src/index.ts | 1 + src/models/material.ts | 2 + src/models/measurement.ts | 2 + src/models/sample.ts | 3 + src/routes/sample.spec.ts | 4 +- src/routes/sample.ts | 213 +++++++++++++++++++++++++--------- src/routes/validate/sample.ts | 7 +- 9 files changed, 180 insertions(+), 61 deletions(-) diff --git a/data_import/import.js b/data_import/import.js index a72ee0c..3d84160 100644 --- a/data_import/import.js +++ b/data_import/import.js @@ -16,6 +16,7 @@ let normMaster = {}; // TODO: integrate measurement device information from DPT names using different users // TODO: supplier: other for supplierless samples +// TODO: BASF twice, BASF as color main(); diff --git a/src/helpers/csv.ts b/src/helpers/csv.ts index a307ca5..18e633c 100644 --- a/src/helpers/csv.ts +++ b/src/helpers/csv.ts @@ -1,7 +1,13 @@ import {parseAsync} from 'json2csv'; export default function csv(input: any[], f: (err, data) => void) { - parseAsync(input.map(e => flatten(e))) + console.log(input[1000]); + console.log(flatten(input[1000])); + parseAsync([flatten(input[1000])]).then(csv => console.log(csv)); + console.log(input[1]); + console.log(flatten(input[1])); + parseAsync([flatten(input[1])]).then(csv => console.log(csv)); + parseAsync(input.map(e => flatten(e)), {includeEmptyRows: true}) .then(csv => f(null, csv)) .catch(err => f(err, null)); } diff --git a/src/index.ts b/src/index.ts index 9af77cf..8116de7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import helmet from 'helmet'; import api from './api'; import db from './db'; +// TODO: working demo branch // tell if server is running in debug or production environment console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); diff --git a/src/models/material.ts b/src/models/material.ts index bcebb83..d7d5eb9 100644 --- a/src/models/material.ts +++ b/src/models/material.ts @@ -22,5 +22,7 @@ MaterialSchema.query.log = function > db.log(req, this); return this; } +MaterialSchema.index({supplier_id: 1}); +MaterialSchema.index({group_id: 1}); export default mongoose.model>('material', MaterialSchema); \ No newline at end of file diff --git a/src/models/measurement.ts b/src/models/measurement.ts index 1136e6b..55267ec 100644 --- a/src/models/measurement.ts +++ b/src/models/measurement.ts @@ -17,5 +17,7 @@ MeasurementSchema.query.log = function >('measurement', MeasurementSchema); \ No newline at end of file diff --git a/src/models/sample.ts b/src/models/sample.ts index 0e457d8..8eec7bd 100644 --- a/src/models/sample.ts +++ b/src/models/sample.ts @@ -22,5 +22,8 @@ SampleSchema.query.log = function > ( db.log(req, this); return this; } +SampleSchema.index({material_id: 1}); +SampleSchema.index({note_id: 1}); +SampleSchema.index({user_id: 1}); export default mongoose.model>('sample', SampleSchema); \ No newline at end of file diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index df7c242..2f82bc9 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -252,7 +252,7 @@ describe('/sample', () => { httpStatus: 200 }).end((err, res) => { if (err) return done(err); - should(res.body.find(e => e.number === '1')).have.property('kf', {}); + should(res.body.find(e => e.number === '1')).have.property('kf', {'weight %': null, 'standard deviation': null}); should(res.body.find(e => e.number === 'Rng36')).have.property('kf', {'weight %': 0.5, 'standard deviation': null}); done(); }); @@ -328,7 +328,7 @@ describe('/sample', () => { url: '/samples?status=all&page-size=1&fields[]=xx', auth: {basic: 'janedoe'}, httpStatus: 400, - res: {status: 'Invalid body format', details: '"fields[0]" with value "xx" fails to match the required pattern: /^(_id|color|number|type|batch|added|material\\.name|material\\.supplier|material\\.group|material\\.mineral|material\\.glass_fiber|material\\.carbon_fiber|material\\.number|condition|material_id|material|note_id|user_id|material\\._id|material\\.numbers|measurements\\..+)$/m'} + res: {status: 'Invalid body format', details: '"fields[0]" with value "xx" fails to match the required pattern: /^(_id|color|number|type|batch|added|material\\.name|material\\.supplier|material\\.group|material\\.mineral|material\\.glass_fiber|material\\.carbon_fiber|material\\.number|measurements\\.(?!spectrum).+|condition|material_id|material|note_id|user_id|material\\._id|material\\.numbers|measurements\\.spectrum)$/m'} }); }); it('rejects a negative page size', done => { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index eef30f9..7b2af04 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -27,21 +27,10 @@ router.get('/samples', async (req, res, next) => { const {error, value: filters} = SampleValidate.query(req.query); if (error) return res400(error, res); - const query = []; - query.push({$match: {$and: []}}); - if (filters.hasOwnProperty('status')) { - if(filters.status === 'all') { - query[0].$match.$and.push({$or: [{status: globals.status.validated}, {status: globals.status.new}]}); - } - else { - query[0].$match.$and.push({status: globals.status[filters.status]}); - } - } - else { // default - query[0].$match.$and.push({status: globals.status.validated}); - } + // TODO: find a better place for these + const sampleKeys = ['_id', 'color', 'number', 'type', 'batch', 'added', 'condition', 'material_id', 'note_id', 'user_id']; - // sorting + // evaluate sort parameter from 'color-asc' to ['color', 1] filters.sort = filters.sort.split('-'); filters.sort[0] = filters.sort[0] === 'added' ? '_id' : filters.sort[0]; // route added sorting criteria to _id filters.sort[1] = filters.sort[1] === 'desc' ? -1 : 1; @@ -49,62 +38,139 @@ router.get('/samples', async (req, res, next) => { filters['to-page'] = 0; } - query.push( - {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, - {$set: {material: { $arrayElemAt: ['$material', 0]}}}, - {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, - {$set: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}}, - {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, - {$set: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}}, - {$set: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]} + let collection; + const query = []; + query.push({$match: {$and: []}}); + + if (filters.sort[0].indexOf('measurements.') >= 0) { // sorting with measurements as starting collection + collection = MeasurementModel; + const measurementName = filters.sort[0].replace('measurements.', ''); + const measurementTemplate = await MeasurementTemplateModel.findOne({name: measurementName}).lean().exec().catch(err => {next(err);}); + if (measurementTemplate instanceof Error) return; + if (!measurementTemplate) { + return res.status(400).json({status: 'Invalid body format', details: filters.sort[0] + ' not found'}); + } + let sortStartValue = null; + if (filters['from-id']) { // from-id specified, fetch values for sorting + const fromSample = await MeasurementModel.findOne({sample_id: mongoose.Types.ObjectId(filters['from-id'])}).lean().exec().catch(err => {next(err);}); // TODO: what if more than one measurement for sample? + if (fromSample instanceof Error) return; + if (!fromSample) { + return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'}); } + sortStartValue = fromSample.values[measurementTemplate.parameters[0].name]; } - ); + query[0].$match.$and.push({measurement_template: mongoose.Types.ObjectId(measurementTemplate._id)}); // find measurements to sort + query.push( + sortQuery(query, filters, ['values.' + measurementTemplate.parameters[0].name, 'sample_id'], sortStartValue), // sort measurements + {$replaceRoot: {newRoot: {measurement: '$$ROOT'}}}, // fetch samples and restructure them to fit sample structure + {$lookup: {from: 'samples', localField: 'measurement.sample_id', foreignField: '_id', as: 'sample'}}, + {$match: statusQuery(filters, 'sample.status')} // filter out wrong status once samples were added + ); + addSkipLimit(query, filters); // skip and limit to select right page + query.push( + {$set: {['sample.' + measurementName]: '$measurement.values'}}, // more restructuring + {$replaceRoot: {newRoot: {$mergeObjects: [{$arrayElemAt: ['$sample', 0]}, {}]}}} + ); - if (filters['from-id']) { // from-id specified - const fromSample = await SampleModel.findById(filters['from-id']).lean().exec().catch(err => {next(err);}); - if (fromSample instanceof Error) return; - if (!fromSample) { - return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'}); - } + } + else { // sorting with samples as starting collection + collection = SampleModel; + // filter for status + query[0].$match.$and.push(statusQuery(filters, 'status')); - if ((filters['to-page'] === 0 && filters.sort[1] === 1) || (filters.sort[1] * filters['to-page'] > 0)) { // asc - query[0].$match.$and.push({$or: [{[filters.sort[0]]: {$gt: fromSample[filters.sort[0]]}}, {$and: [{[filters.sort[0]]: fromSample[filters.sort[0]]}, {_id: {$gte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}); - query.push({$sort: {[filters.sort[0]]: 1, _id: 1}}); + // differentiate by sort key to do sorting, skip and limit as early as possible + if (sampleKeys.indexOf(filters.sort[0]) >= 0) { // sorting for sample keys + let sortStartValue = null; + if (filters['from-id']) { // from-id specified + const fromSample = await SampleModel.findById(filters['from-id']).lean().exec().catch(err => {next(err);}); + if (fromSample instanceof Error) return; + if (!fromSample) { + return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'}); + } + sortStartValue = fromSample[filters.sort[0]]; + } + query.push(sortQuery(query, filters, [filters.sort[0], '_id'], sortStartValue)); + addSkipLimit(query, filters); } - else { - query[0].$match.$and.push({$or: [{[filters.sort[0]]: {$lt: fromSample[filters.sort[0]]}}, {$and: [{[filters.sort[0]]: fromSample[filters.sort[0]]}, {_id: {$lte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}); - query.push({$sort: {[filters.sort[0]]: -1, _id: -1}}); + else { // sorting for material keys + let materialQuery = [] + materialQuery.push( // add material properties + {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, + {$set: {material: { $arrayElemAt: ['$material', 0]}}} + ); + if (filters.sort[0] === 'material.supplier') { // add supplier if needed + materialQuery.push( + {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, + {$set: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} + ); + } + if (filters.sort[0] === 'material.group') { // add group if needed + materialQuery.push( + {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, + {$set: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}} + ); + } + if (filters.sort[0] === 'material.number') { // add material number if needed + materialQuery.push( + {$set: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}} + ); + } + query.push(...materialQuery); + let sortStartValue = null; + if (filters['from-id']) { // from-id specified + const fromSample = await SampleModel.aggregate([{$match: {_id: mongoose.Types.ObjectId(filters['from-id'])}}, ...materialQuery]).exec().catch(err => {next(err);}); + if (fromSample instanceof Error) return; + if (!fromSample) { + return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'}); + } + sortStartValue = fromSample[filters.sort[0]]; + } + query.push(sortQuery(query, filters, [filters.sort[0], '_id'], sortStartValue)); + addSkipLimit(query, filters); } } - else { // sort from beginning - query.push({$sort: {[filters.sort[0]]: filters.sort[1], '_id': filters.sort[1]}}); // set _id as secondary sort + + const fieldsNoSort = filters.fields.filter(e => e !== filters.sort[0]); // sort field was definitely added already, exclude from further field operations + if (fieldsNoSort.find(e => /material\./.test(e))) { // add material fields + query.push( + {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, + {$set: {material: { $arrayElemAt: ['$material', 0]}}} + ); + } + if (fieldsNoSort.indexOf('material.supplier') >= 0) { // add supplier if needed + query.push( + {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, + {$set: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} + ); + } + if (fieldsNoSort.indexOf('material.group') >= 0) { // add group if needed + query.push( + {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, + {$set: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}} + ); + } + if (fieldsNoSort.indexOf('material.number') >= 0) { // add material number if needed + query.push( + {$set: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}} + ); } - if (filters['to-page']) { - query.push({$skip: Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size'] + Number(filters['to-page'] < 0)}) // number to skip, if going back pages, one page has to be skipped less but on sample more - } - - if (filters['page-size']) { - query.push({$limit: filters['page-size']}); - } - - let measurementFields = []; - if (filters.fields.find(e => /measurements\./.test(e))) { // joining measurements is required + let measurementFields = filters.fields.filter(e => /measurements\./.test(e)).map(e => e.replace('measurements.', '')); + console.log(fieldsNoSort); + console.log(measurementFields); + if (fieldsNoSort.find(e => /measurements\./.test(e))) { // add measurement fields query.push({$lookup: {from: 'measurements', localField: '_id', foreignField: 'sample_id', as: 'measurements'}}); - measurementFields = filters.fields.filter(e => /measurements\./.test(e)).map(e => e.replace('measurements.', '')); - const measurementTemplates = await MeasurementTemplateModel.find({$or: measurementFields.map(e => {return {name: e}})}).lean().exec().catch(err => {next(err);}); + const measurementTemplates = await MeasurementTemplateModel.find({$or: measurementFields.filter(e => e !== filters.sort[0].replace('measurements.', '')).map(e => {return {name: e}})}).lean().exec().catch(err => {next(err);}); if (measurementTemplates instanceof Error) return; if (measurementTemplates.length < measurementFields.length) { return res.status(400).json({status: 'Invalid body format', details: 'Measurement key not found'}); } measurementTemplates.filter(e => e.name !== 'spectrum').forEach(template => { // TODO: hard coded dpt for special treatment, change later query.push({$set: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values - vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}}, - in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']} - }}}}, {$set: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', {}]}}}); + vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}}, + in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']} + }}}}, {$set: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', template.parameters.reduce((s, e) => {s[e.name] = null; return s;}, {})]}}}); }); - console.log(measurementFields); if (measurementFields.find(e => e === 'spectrum')) { // TODO: remove hardcoded as well query.push( {$set: {spectrum: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', measurementTemplates.filter(e => e.name === 'spectrum')[0]._id]}}}}}, @@ -124,12 +190,11 @@ router.get('/samples', async (req, res, next) => { } query.push({$project: projection}); - SampleModel.aggregate(query).exec((err, data) => { + collection.aggregate(query).exec((err, data) => { if (err) return next(err); if (filters['to-page'] < 0) { data.reverse(); } - console.log(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields)))); if (filters.csv) { // output as csv csv(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields))), (err, data) => { if (err) return next(err); @@ -498,4 +563,42 @@ function customFieldsChange (fields, amount, req) { // update custom_fields and } }); }); +} + +function sortQuery(query, filters, sortKeys, sortStartValue) { // sortKeys = ['primary key', 'secondary key'] + if (filters['from-id']) { // from-id specified + if ((filters['to-page'] === 0 && filters.sort[1] === 1) || (filters.sort[1] * filters['to-page'] > 0)) { // asc + query[0].$match.$and.push({$or: [{[sortKeys[0]]: {$gt: sortStartValue}}, {$and: [{[sortKeys[0]]: sortStartValue}, {[sortKeys[1]]: {$gte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}); + return {$sort: {[sortKeys[0]]: 1, _id: 1}}; + } else { + query[0].$match.$and.push({$or: [{[sortKeys[0]]: {$lt: sortStartValue}}, {$and: [{[sortKeys[0]]: sortStartValue}, {[sortKeys[1]]: {$lte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}); + return {$sort: {[sortKeys[0]]: -1, _id: -1}}; + } + } else { // sort from beginning + return {$sort: {[sortKeys[0]]: filters.sort[1], [sortKeys[1]]: filters.sort[1]}}; // set _id as secondary sort + } +} + +function addSkipLimit(query, filters) { + if (filters['to-page']) { + query.push({$skip: Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size'] + Number(filters['to-page'] < 0)}) // number to skip, if going back pages, one page has to be skipped less but on sample more + } + + if (filters['page-size']) { + query.push({$limit: filters['page-size']}); + } +} + +function statusQuery(filters, field) { + if (filters.hasOwnProperty('status')) { + if(filters.status === 'all') { + return {$or: [{[field]: globals.status.validated}, {[field]: globals.status.new}]}; + } + else { + return {[field]: globals.status[filters.status]}; + } + } + else { // default + return {[field]: globals.status.validated}; + } } \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 41435dd..16bc8a1 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -64,7 +64,8 @@ export default class SampleValidate { 'material.mineral', 'material.glass_fiber', 'material.carbon_fiber', - 'material.number' + 'material.number', + 'measurements.(?!spectrum)*' ]; private static fieldKeys = [ @@ -76,7 +77,7 @@ export default class SampleValidate { 'user_id', 'material._id', 'material.numbers', - 'measurements.*' + 'measurements.spectrum' ]; static input (data, param) { // validate input, set param to 'new' to make all attributes required @@ -168,7 +169,7 @@ export default class SampleValidate { 'from-id': IdValidate.get(), 'to-page': Joi.number().integer(), 'page-size': Joi.number().integer().min(1), - sort: Joi.string().pattern(new RegExp('^(' + this.sortKeys.join('|').replace(/\./g, '\\.') + ')-(asc|desc)$', 'm')).default('_id-asc'), + sort: Joi.string().pattern(new RegExp('^(' + this.sortKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')-(asc|desc)$', 'm')).default('_id-asc'), csv: Joi.boolean().default(false), fields: Joi.array().items(Joi.string().pattern(new RegExp('^(' + this.fieldKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')$', 'm'))).default(['_id','number','type','batch','material_id','color','condition','note_id','user_id','added']) }).with('to-page', 'page-size').validate(data); From 29eefce0c9b6a641690a1089fe43f20a80a8f0ee Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 6 Jul 2020 09:43:04 +0200 Subject: [PATCH 77/83] added filters --- api/sample.yaml | 8 +++ data_import/import.js | 2 + src/routes/sample.spec.ts | 105 +++++++++++++++++++++++++++++++++- src/routes/sample.ts | 57 +++++++++++++----- src/routes/validate/sample.ts | 19 +++++- src/test/db.json | 2 +- 6 files changed, 176 insertions(+), 17 deletions(-) diff --git a/api/sample.yaml b/api/sample.yaml index 91c57e0..acdd33c 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -50,6 +50,14 @@ items: type: string example: ['number', 'batch'] + - name: filters[] + description: "the filters to apply as an array of URIComponent encoded objects in the form {mode: 'eq/ne/lt/lte/gt/gte/in/nin', field: 'material.m', values: ['15']} using encodeURIComponent(JSON.stringify({}))" + in: query + schema: + type: array + items: + type: string + example: ["%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22material.m%22%2C%22values%22%3A%5B%2215%22%5D%7D", "%7B%22mode%22%3A%22isin%22%2C%22field%22%3A%22material.supplier%22%2C%22values%22%3A%5B%22BASF%22%2C%22DSM%22%5D%7D"] responses: 200: description: samples overview (if the csv parameter is set, this is in CSV instead of JSON format) diff --git a/data_import/import.js b/data_import/import.js index 3d84160..4f31f8b 100644 --- a/data_import/import.js +++ b/data_import/import.js @@ -17,6 +17,8 @@ let normMaster = {}; // TODO: integrate measurement device information from DPT names using different users // TODO: supplier: other for supplierless samples // TODO: BASF twice, BASF as color +// TODO: trim color names +// TODO: duplicate kf values main(); diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 2f82bc9..d15200b 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -20,6 +20,7 @@ describe('/sample', () => { after(done => TestHelper.after(done)); // TODO: sort, added date filter, has measurements/condition filter + // TODO: check if conditions work in sort/fields/filters describe('GET /samples', () => { it('returns all samples', done => { TestHelper.request(server, done, { @@ -253,7 +254,7 @@ describe('/sample', () => { }).end((err, res) => { if (err) return done(err); should(res.body.find(e => e.number === '1')).have.property('kf', {'weight %': null, 'standard deviation': null}); - should(res.body.find(e => e.number === 'Rng36')).have.property('kf', {'weight %': 0.5, 'standard deviation': null}); + should(res.body.find(e => e.number === 'Rng36')).have.property('kf', {'weight %': 0.6, 'standard deviation': null}); done(); }); }); @@ -271,6 +272,108 @@ describe('/sample', () => { done(); }); }); + it('filters a sample property', done => { // TODO: implement filters + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=type&filters[]=%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22type%22%2C%22values%22%3A%5B%22part%22%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.type === 'part').length); + should(res.body).matchEach(sample => { + should(sample).have.property('type', 'part'); + }); + done(); + }); + }); + it('filters a material property', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.name&filters[]=%7B%22mode%22%3A%22in%22%2C%22field%22%3A%22material.name%22%2C%22values%22%3A%5B%22Schulamid%2066%20GF%2025%20H%22%2C%22Stanyl%20TW%20200%20F8%22%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.material_id == '100000000000000000000004' || e.material_id == '100000000000000000000001').length); + should(res.body).matchEach(sample => { + should(sample.material.name).be.equalOneOf('Schulamid 66 GF 25 H', 'Stanyl TW 200 F8'); + }); + done(); + }); + }); + it('filters by measurement value', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.name&fields[]=measurements.kf.weight%20%25&filters[]=%7B%22mode%22%3A%22gt%22%2C%22field%22%3A%22measurements.kf.weight%20%25%22%2C%22values%22%3A%5B0.5%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.measurements.filter(e => e.measurement_template == '300000000000000000000002' && e.values['weight %'] > 0.5).length); + should(res.body).matchEach(sample => { + should(sample.kf['weight %']).be.above(0.5); + }); + done(); + }); + }); + it('filters by measurement value not in the fields', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.name&filters[]=%7B%22mode%22%3A%22gt%22%2C%22field%22%3A%22measurements.kf.weight%20%25%22%2C%22values%22%3A%5B0.5%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + should(res.body).have.lengthOf(json.collections.measurements.filter(e => e.measurement_template == '300000000000000000000002' && e.values['weight %'] > 0.5).length); + should(res.body[0]).have.property('number', 'Rng36'); + done(); + }); + }); + it('filters multiple properties', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.glass_fiber&fields[]=batch&filters[]=%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22material.glass_fiber%22%2C%22values%22%3A%5B33%5D%7D&filters[]=%7B%22mode%22%3A%22lte%22%2C%22field%22%3A%22number%22%2C%22values%22%3A%5B%22Rng33%22%5D%7D&filters[]=%7B%22mode%22%3A%22nin%22%2C%22field%22%3A%22batch%22%2C%22values%22%3A%5B%221704-005%22%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(1); + should(res.body[0]).be.eql({number: '32', material: {glass_fiber: 33}, batch: '1653000308'}); + done(); + }); + }); // TODO: do measurement pipeline, check if it works with UI + it('rejects an invalid JSON string as a filters parameter', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.glass_fiber&fields[]=batch&filters[]=xx', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: 'Invalid JSON string for filter parameter'} + }); + }); + it('rejects an invalid filter mode', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.glass_fiber&fields[]=batch&filters[]=%7B%22mode%22%3A%22xx%22%2C%22field%22%3A%22batch%22%2C%22values%22%3A%5B%221704-005%22%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"filters[0].mode" must be one of [eq, ne, lt, lte, gt, gte, in, nin]'} + }); + }); + it('rejects an filter field not existing', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples?status=all&fields[]=number&fields[]=material.glass_fiber&fields[]=batch&filters[]=%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22xx%22%2C%22values%22%3A%5B%221704-005%22%5D%7D', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"filters[0].field" with value "xx" fails to match the required pattern: /^(_id|color|number|type|batch|added|material\\.name|material\\.supplier|material\\.group|material\\.mineral|material\\.glass_fiber|material\\.carbon_fiber|material\\.number|measurements\\.(?!spectrum).+|condition|material_id|material|note_id|user_id|material\\._id|material\\.numbers|measurements\\.spectrum)$/m'} + }); + }); it('rejects unknown measurement names', done => { TestHelper.request(server, done, { method: 'get', diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 7b2af04..240c156 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -44,7 +44,7 @@ router.get('/samples', async (req, res, next) => { if (filters.sort[0].indexOf('measurements.') >= 0) { // sorting with measurements as starting collection collection = MeasurementModel; - const measurementName = filters.sort[0].replace('measurements.', ''); + const [,measurementName, measurementParam] = filters.sort[0].split('.'); const measurementTemplate = await MeasurementTemplateModel.findOne({name: measurementName}).lean().exec().catch(err => {next(err);}); if (measurementTemplate instanceof Error) return; if (!measurementTemplate) { @@ -57,11 +57,14 @@ router.get('/samples', async (req, res, next) => { if (!fromSample) { return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'}); } - sortStartValue = fromSample.values[measurementTemplate.parameters[0].name]; + sortStartValue = fromSample.values[measurementParam]; } query[0].$match.$and.push({measurement_template: mongoose.Types.ObjectId(measurementTemplate._id)}); // find measurements to sort + if (filters.filters.find(e => e.field === filters.sort[0])) { // sorted measurement should also be filtered + query[0].$match.$and.push(...filterQueries(filters.filters.find(e => e.field === filters.sort[0]))); + } query.push( - sortQuery(query, filters, ['values.' + measurementTemplate.parameters[0].name, 'sample_id'], sortStartValue), // sort measurements + sortQuery(query, filters, ['values.' + measurementParam, 'sample_id'], sortStartValue), // sort measurements {$replaceRoot: {newRoot: {measurement: '$$ROOT'}}}, // fetch samples and restructure them to fit sample structure {$lookup: {from: 'samples', localField: 'measurement.sample_id', foreignField: '_id', as: 'sample'}}, {$match: statusQuery(filters, 'sample.status')} // filter out wrong status once samples were added @@ -71,12 +74,13 @@ router.get('/samples', async (req, res, next) => { {$set: {['sample.' + measurementName]: '$measurement.values'}}, // more restructuring {$replaceRoot: {newRoot: {$mergeObjects: [{$arrayElemAt: ['$sample', 0]}, {}]}}} ); - + addFilterQueries(query, filters.filters.filter(e => sampleKeys.indexOf(e.field) >= 0)); // sample filters } else { // sorting with samples as starting collection collection = SampleModel; // filter for status query[0].$match.$and.push(statusQuery(filters, 'status')); + addFilterQueries(query, filters.filters.filter(e => sampleKeys.indexOf(e.field) >= 0)); // sample filters // differentiate by sort key to do sorting, skip and limit as early as possible if (sampleKeys.indexOf(filters.sort[0]) >= 0) { // sorting for sample keys @@ -90,6 +94,7 @@ router.get('/samples', async (req, res, next) => { sortStartValue = fromSample[filters.sort[0]]; } query.push(sortQuery(query, filters, [filters.sort[0], '_id'], sortStartValue)); + // material filters addSkipLimit(query, filters); } else { // sorting for material keys @@ -130,41 +135,54 @@ router.get('/samples', async (req, res, next) => { } } - const fieldsNoSort = filters.fields.filter(e => e !== filters.sort[0]); // sort field was definitely added already, exclude from further field operations - if (fieldsNoSort.find(e => /material\./.test(e))) { // add material fields + const fieldsToAdd = [ + ...filters.fields, + ...filters.filters.map(e => e.field) // add filter fields in case they were not specified to display + ].filter(e => e !== filters.sort[0]) // sort field was definitely added already, exclude from further field operations + .filter((e, i, self) => self.indexOf(e) === i); // remove duplicates + if (fieldsToAdd.find(e => /material\./.test(e))) { // add material fields query.push( {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, {$set: {material: { $arrayElemAt: ['$material', 0]}}} ); } - if (fieldsNoSort.indexOf('material.supplier') >= 0) { // add supplier if needed + if (fieldsToAdd.indexOf('material.supplier') >= 0) { // add supplier if needed query.push( {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, {$set: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} ); } - if (fieldsNoSort.indexOf('material.group') >= 0) { // add group if needed + if (fieldsToAdd.indexOf('material.group') >= 0) { // add group if needed query.push( {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, {$set: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}} ); } - if (fieldsNoSort.indexOf('material.number') >= 0) { // add material number if needed + if (fieldsToAdd.indexOf('material.number') >= 0) { // add material number if needed query.push( {$set: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}} ); } - let measurementFields = filters.fields.filter(e => /measurements\./.test(e)).map(e => e.replace('measurements.', '')); - console.log(fieldsNoSort); - console.log(measurementFields); - if (fieldsNoSort.find(e => /measurements\./.test(e))) { // add measurement fields - query.push({$lookup: {from: 'measurements', localField: '_id', foreignField: 'sample_id', as: 'measurements'}}); + addFilterQueries(query, filters.filters.filter(e => /material\./.test(e.field))); // material filters + + let measurementFields = fieldsToAdd.filter(e => /measurements\./.test(e)).map(e => e.split('.')[1]).filter((e, i, self) => self.indexOf(e) === i); // filter measurement names and remove duplicates from parameters + if (fieldsToAdd.find(e => /measurements\./.test(e))) { // add measurement fields const measurementTemplates = await MeasurementTemplateModel.find({$or: measurementFields.filter(e => e !== filters.sort[0].replace('measurements.', '')).map(e => {return {name: e}})}).lean().exec().catch(err => {next(err);}); if (measurementTemplates instanceof Error) return; if (measurementTemplates.length < measurementFields.length) { return res.status(400).json({status: 'Invalid body format', details: 'Measurement key not found'}); } + if (fieldsToAdd.find(e => e === 'measurements.spectrum')) { // use different lookup methods with and without spectrum for the best performance + query.push({$lookup: {from: 'measurements', localField: '_id', foreignField: 'sample_id', as: 'measurements'}}); + } + else { + query.push({$lookup: { + from: 'measurements', let: {sId: '$_id'}, + pipeline: [{$match: {$expr: {$and: [{$eq: ['$sample_id', '$$sId']}, {$in: ['$measurement_template', measurementTemplates.map(e => mongoose.Types.ObjectId(e._id))]}]}}}], + as: 'measurements' + }}); + } measurementTemplates.filter(e => e.name !== 'spectrum').forEach(template => { // TODO: hard coded dpt for special treatment, change later query.push({$set: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}}, @@ -180,6 +198,7 @@ router.get('/samples', async (req, res, next) => { } query.push({$unset: 'measurements'}); } + addFilterQueries(query, filters.filters.filter(e => /measurements\./.test(e.field)).map(e => {e.field = e.field.replace('measurements.', ''); return e; })); // measurement filters const projection = filters.fields.map(e => e.replace('measurements.', '')).reduce((s, e) => {s[e] = true; return s; }, {}); if (filters.fields.indexOf('added') >= 0) { // add added date @@ -601,4 +620,14 @@ function statusQuery(filters, field) { else { // default return {[field]: globals.status.validated}; } +} + +function addFilterQueries (query, filters) { // returns array of match queries from given filters + if (filters.length) { + query.push({$match: {$and: filterQueries(filters)}}); + } +} + +function filterQueries (filters) { + return filters.map(e => ({[e.field]: {['$' + e.mode]: (e.mode.indexOf('in') >= 0 ? e.values : e.values[0])}})) // add filter criteria as {field: {$mode: value}}, only use first value when mode is not in/nin } \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 16bc8a1..b0cae01 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -164,6 +164,18 @@ export default class SampleValidate { } static query (data) { + if (data.filters && data.filters.length) { + const filterValidation = Joi.array().items(Joi.string()).validate(data.filters); + if (filterValidation.error) return filterValidation; + try { + for (let i in data.filters) { + data.filters[i] = JSON.parse(data.filters[i]); + } + } + catch { + return {error: {details: [{message: 'Invalid JSON string for filter parameter'}]}, value: null} + } + } return Joi.object({ status: Joi.string().valid('validated', 'new', 'all'), 'from-id': IdValidate.get(), @@ -171,7 +183,12 @@ export default class SampleValidate { 'page-size': Joi.number().integer().min(1), sort: Joi.string().pattern(new RegExp('^(' + this.sortKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')-(asc|desc)$', 'm')).default('_id-asc'), csv: Joi.boolean().default(false), - fields: Joi.array().items(Joi.string().pattern(new RegExp('^(' + this.fieldKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')$', 'm'))).default(['_id','number','type','batch','material_id','color','condition','note_id','user_id','added']) + fields: Joi.array().items(Joi.string().pattern(new RegExp('^(' + this.fieldKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')$', 'm'))).default(['_id','number','type','batch','material_id','color','condition','note_id','user_id','added']), + filters: Joi.array().items(Joi.object({ + mode: Joi.string().valid('eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'nin'), + field: Joi.string().pattern(new RegExp('^(' + this.fieldKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')$', 'm')), + values: Joi.array().items(Joi.alternatives().try(Joi.string().max(128), Joi.number(), Joi.boolean())).min(1) + })).default([]) }).with('to-page', 'page-size').validate(data); } } \ No newline at end of file diff --git a/src/test/db.json b/src/test/db.json index aa68283..99ae417 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -411,7 +411,7 @@ "_id": {"$oid":"800000000000000000000006"}, "sample_id": {"$oid":"400000000000000000000006"}, "values": { - "weight %": 0.5, + "weight %": 0.6, "standard deviation":null }, "status": 0, From 6a02f09e7f1689c9a6ee7cff8f77a44b70bf3c32 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Mon, 6 Jul 2020 16:57:09 +0200 Subject: [PATCH 78/83] reworked filters --- src/routes/sample.ts | 144 ++++++++++++++++++++++++++----------------- 1 file changed, 88 insertions(+), 56 deletions(-) diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 240c156..11080f7 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -38,6 +38,8 @@ router.get('/samples', async (req, res, next) => { filters['to-page'] = 0; } + const sortFilterKeys = filters.filters.map(e => e.field); + let collection; const query = []; query.push({$match: {$and: []}}); @@ -64,13 +66,10 @@ router.get('/samples', async (req, res, next) => { query[0].$match.$and.push(...filterQueries(filters.filters.find(e => e.field === filters.sort[0]))); } query.push( - sortQuery(query, filters, ['values.' + measurementParam, 'sample_id'], sortStartValue), // sort measurements + ...sortQuery(query, filters, ['values.' + measurementParam, 'sample_id'], sortStartValue), // sort measurements {$replaceRoot: {newRoot: {measurement: '$$ROOT'}}}, // fetch samples and restructure them to fit sample structure {$lookup: {from: 'samples', localField: 'measurement.sample_id', foreignField: '_id', as: 'sample'}}, - {$match: statusQuery(filters, 'sample.status')} // filter out wrong status once samples were added - ); - addSkipLimit(query, filters); // skip and limit to select right page - query.push( + {$match: statusQuery(filters, 'sample.status')}, // filter out wrong status once samples were added {$set: {['sample.' + measurementName]: '$measurement.values'}}, // more restructuring {$replaceRoot: {newRoot: {$mergeObjects: [{$arrayElemAt: ['$sample', 0]}, {}]}}} ); @@ -78,49 +77,60 @@ router.get('/samples', async (req, res, next) => { } else { // sorting with samples as starting collection collection = SampleModel; - // filter for status query[0].$match.$and.push(statusQuery(filters, 'status')); - addFilterQueries(query, filters.filters.filter(e => sampleKeys.indexOf(e.field) >= 0)); // sample filters - // differentiate by sort key to do sorting, skip and limit as early as possible if (sampleKeys.indexOf(filters.sort[0]) >= 0) { // sorting for sample keys let sortStartValue = null; if (filters['from-id']) { // from-id specified - const fromSample = await SampleModel.findById(filters['from-id']).lean().exec().catch(err => {next(err);}); + const fromSample = await SampleModel.findById(filters['from-id']).lean().exec().catch(err => { + next(err); + }); if (fromSample instanceof Error) return; if (!fromSample) { return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'}); } sortStartValue = fromSample[filters.sort[0]]; } - query.push(sortQuery(query, filters, [filters.sort[0], '_id'], sortStartValue)); - // material filters - addSkipLimit(query, filters); + query.push(...sortQuery(query, filters, [filters.sort[0], '_id'], sortStartValue)); } - else { // sorting for material keys - let materialQuery = [] - materialQuery.push( // add material properties - {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, - {$set: {material: { $arrayElemAt: ['$material', 0]}}} + else { // add sort key to list to add field later + sortFilterKeys.push(filters.sort[0]); + } + } + + addFilterQueries(query, filters.filters.filter(e => sampleKeys.indexOf(e.field) >= 0)); // sample filters + + let materialQuery = []; // put material query together separate first to reuse for first-id + let materialAdded = false; + if (sortFilterKeys.find(e => /material\./.test(e))) { // add material fields + materialAdded = true; + materialQuery.push( // add material properties + {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, // TODO: project out unnecessary fields + {$set: {material: {$arrayElemAt: ['$material', 0]}}} + ); + const baseMFilters = sortFilterKeys.filter(e => /material\./.test(e)).filter(e => ['material.supplier', 'material.group', 'material.number'].indexOf(e) < 0); + addFilterQueries(materialQuery, filters.filters.filter(e => baseMFilters.indexOf(e.field) >= 0)); // base material filters + if (sortFilterKeys.find(e => e === 'material.supplier')) { // add supplier if needed + materialQuery.push( + {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, + {$set: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} ); - if (filters.sort[0] === 'material.supplier') { // add supplier if needed - materialQuery.push( - {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, - {$set: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} - ); - } - if (filters.sort[0] === 'material.group') { // add group if needed - materialQuery.push( - {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, - {$set: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}} - ); - } - if (filters.sort[0] === 'material.number') { // add material number if needed - materialQuery.push( - {$set: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}} - ); - } - query.push(...materialQuery); + } + if (sortFilterKeys.find(e => e === 'material.group')) { // add group if needed + materialQuery.push( + {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, + {$set: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}} + ); + } + if (sortFilterKeys.find(e => e === 'material.number')) { // add material number if needed + materialQuery.push( + {$set: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}} + ); + } + const specialMFilters = sortFilterKeys.filter(e => /material\./.test(e)).filter(e => ['material.supplier', 'material.group', 'material.number'].indexOf(e) >= 0); + addFilterQueries(materialQuery, filters.filters.filter(e => specialMFilters.indexOf(e.field) >= 0)); // base material filters + query.push(...materialQuery); + if (/material\./.test(filters.sort[0])) { // sort by material key let sortStartValue = null; if (filters['from-id']) { // from-id specified const fromSample = await SampleModel.aggregate([{$match: {_id: mongoose.Types.ObjectId(filters['from-id'])}}, ...materialQuery]).exec().catch(err => {next(err);}); @@ -130,17 +140,41 @@ router.get('/samples', async (req, res, next) => { } sortStartValue = fromSample[filters.sort[0]]; } - query.push(sortQuery(query, filters, [filters.sort[0], '_id'], sortStartValue)); - addSkipLimit(query, filters); + query.push(...sortQuery(query, filters, [filters.sort[0], '_id'], sortStartValue)); } } - const fieldsToAdd = [ - ...filters.fields, - ...filters.filters.map(e => e.field) // add filter fields in case they were not specified to display - ].filter(e => e !== filters.sort[0]) // sort field was definitely added already, exclude from further field operations - .filter((e, i, self) => self.indexOf(e) === i); // remove duplicates - if (fieldsToAdd.find(e => /material\./.test(e))) { // add material fields + const measurementFilterFields = _.uniq(sortFilterKeys.filter(e => /measurements\./.test(e)).map(e => e.split('.')[1])); // filter measurement names and remove duplicates from parameters + if (sortFilterKeys.find(e => /measurements\./.test(e))) { // add measurement fields + const measurementTemplates = await MeasurementTemplateModel.find({name: {$in: measurementFilterFields}}).lean().exec().catch(err => {next(err);}); + if (measurementTemplates instanceof Error) return; + if (measurementTemplates.length < measurementFilterFields.length) { + return res.status(400).json({status: 'Invalid body format', details: 'Measurement key not found'}); + } + query.push({$lookup: { + from: 'measurements', let: {sId: '$_id'}, + pipeline: [{$match: {$expr: {$and: [{$eq: ['$sample_id', '$$sId']}, {$in: ['$measurement_template', measurementTemplates.map(e => mongoose.Types.ObjectId(e._id))]}]}}}], + as: 'measurements' + }}); + measurementTemplates.forEach(template => { + query.push({$set: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values + vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}}, + in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']} + }}}}, {$set: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', template.parameters.reduce((s, e) => {s[e.name] = null; return s;}, {})]}}}); + }); + addFilterQueries(query, filters.filters + .filter(e => sortFilterKeys.filter(e => /measurements\./.test(e)).indexOf(e.field) >= 0) + .map(e => {e.field = e.field.replace('measurements.', ''); return e; }) + ); // measurement filters + } + addSkipLimit(query, filters); + + const fieldsToAdd = filters.fields.filter(e => // fields to add + sortFilterKeys.indexOf(e) < 0 // field was not in filter + && e !== filters.sort[0] // field was not in sort + ); + + if (fieldsToAdd.find(e => /material\./.test(e)) && !materialAdded) { // add material, was not added already query.push( {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, {$set: {material: { $arrayElemAt: ['$material', 0]}}} @@ -164,13 +198,11 @@ router.get('/samples', async (req, res, next) => { ); } - addFilterQueries(query, filters.filters.filter(e => /material\./.test(e.field))); // material filters - - let measurementFields = fieldsToAdd.filter(e => /measurements\./.test(e)).map(e => e.split('.')[1]).filter((e, i, self) => self.indexOf(e) === i); // filter measurement names and remove duplicates from parameters + let measurementFieldsFields = _.uniq(fieldsToAdd.filter(e => /measurements\./.test(e)).map(e => e.split('.')[1])); // filter measurement names and remove duplicates from parameters if (fieldsToAdd.find(e => /measurements\./.test(e))) { // add measurement fields - const measurementTemplates = await MeasurementTemplateModel.find({$or: measurementFields.filter(e => e !== filters.sort[0].replace('measurements.', '')).map(e => {return {name: e}})}).lean().exec().catch(err => {next(err);}); + const measurementTemplates = await MeasurementTemplateModel.find({name: {$in: measurementFieldsFields}}).lean().exec().catch(err => {next(err);}); if (measurementTemplates instanceof Error) return; - if (measurementTemplates.length < measurementFields.length) { + if (measurementTemplates.length < measurementFieldsFields.length) { return res.status(400).json({status: 'Invalid body format', details: 'Measurement key not found'}); } if (fieldsToAdd.find(e => e === 'measurements.spectrum')) { // use different lookup methods with and without spectrum for the best performance @@ -181,7 +213,7 @@ router.get('/samples', async (req, res, next) => { from: 'measurements', let: {sId: '$_id'}, pipeline: [{$match: {$expr: {$and: [{$eq: ['$sample_id', '$$sId']}, {$in: ['$measurement_template', measurementTemplates.map(e => mongoose.Types.ObjectId(e._id))]}]}}}], as: 'measurements' - }}); + }}); } measurementTemplates.filter(e => e.name !== 'spectrum').forEach(template => { // TODO: hard coded dpt for special treatment, change later query.push({$set: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values @@ -189,7 +221,7 @@ router.get('/samples', async (req, res, next) => { in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']} }}}}, {$set: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', template.parameters.reduce((s, e) => {s[e.name] = null; return s;}, {})]}}}); }); - if (measurementFields.find(e => e === 'spectrum')) { // TODO: remove hardcoded as well + if (measurementFieldsFields.find(e => e === 'spectrum')) { // TODO: remove hardcoded as well query.push( {$set: {spectrum: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', measurementTemplates.filter(e => e.name === 'spectrum')[0]._id]}}}}}, {$set: {spectrum: '$spectrum.values.dpt'}}, @@ -198,7 +230,6 @@ router.get('/samples', async (req, res, next) => { } query.push({$unset: 'measurements'}); } - addFilterQueries(query, filters.filters.filter(e => /measurements\./.test(e.field)).map(e => {e.field = e.field.replace('measurements.', ''); return e; })); // measurement filters const projection = filters.fields.map(e => e.replace('measurements.', '')).reduce((s, e) => {s[e] = true; return s; }, {}); if (filters.fields.indexOf('added') >= 0) { // add added date @@ -214,6 +245,7 @@ router.get('/samples', async (req, res, next) => { if (filters['to-page'] < 0) { data.reverse(); } + const measurementFields = _.uniq([...measurementFilterFields, ...measurementFieldsFields]); if (filters.csv) { // output as csv csv(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields))), (err, data) => { if (err) return next(err); @@ -587,14 +619,14 @@ function customFieldsChange (fields, amount, req) { // update custom_fields and function sortQuery(query, filters, sortKeys, sortStartValue) { // sortKeys = ['primary key', 'secondary key'] if (filters['from-id']) { // from-id specified if ((filters['to-page'] === 0 && filters.sort[1] === 1) || (filters.sort[1] * filters['to-page'] > 0)) { // asc - query[0].$match.$and.push({$or: [{[sortKeys[0]]: {$gt: sortStartValue}}, {$and: [{[sortKeys[0]]: sortStartValue}, {[sortKeys[1]]: {$gte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}); - return {$sort: {[sortKeys[0]]: 1, _id: 1}}; + return [{$match: {$or: [{[sortKeys[0]]: {$gt: sortStartValue}}, {$and: [{[sortKeys[0]]: sortStartValue}, {[sortKeys[1]]: {$gte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}}, + {$sort: {[sortKeys[0]]: 1, _id: 1}}]; } else { - query[0].$match.$and.push({$or: [{[sortKeys[0]]: {$lt: sortStartValue}}, {$and: [{[sortKeys[0]]: sortStartValue}, {[sortKeys[1]]: {$lte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}); - return {$sort: {[sortKeys[0]]: -1, _id: -1}}; + return [{$match: {$or: [{[sortKeys[0]]: {$lt: sortStartValue}}, {$and: [{[sortKeys[0]]: sortStartValue}, {[sortKeys[1]]: {$lte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}}, + {$sort: {[sortKeys[0]]: -1, _id: -1}}]; } } else { // sort from beginning - return {$sort: {[sortKeys[0]]: filters.sort[1], [sortKeys[1]]: filters.sort[1]}}; // set _id as secondary sort + return [{$sort: {[sortKeys[0]]: filters.sort[1], [sortKeys[1]]: filters.sort[1]}}]; // set _id as secondary sort } } From 1ddc2b617aeb858024be386ea1d7effd2c107355 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 9 Jul 2020 13:48:27 +0200 Subject: [PATCH 79/83] spectrum field working again --- api/api.yaml | 4 +- api/sample.yaml | 8 +- data_import/import.js | 209 +++++++++++++++++++++++----------- mainfest.yml => manifest.yml | 5 +- package-lock.json | 14 +++ package.json | 4 +- src/api.ts | 1 + src/helpers/csv.ts | 6 - src/helpers/mail.ts | 2 +- src/index.ts | 3 + src/routes/sample.spec.ts | 1 + src/routes/sample.ts | 95 ++++++++++------ src/routes/validate/sample.ts | 32 +++++- 13 files changed, 269 insertions(+), 115 deletions(-) rename mainfest.yml => manifest.yml (75%) diff --git a/api/api.yaml b/api/api.yaml index d281206..a1966fa 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -39,10 +39,10 @@ info: servers: + - url: https://definma-api.apps.de1.bosch-iot-cloud.com + description: server on the BIC - url: http://localhost:3000 description: local server - - url: https://digital-fingerprint-of-plastics-api.apps.de1.bosch-iot-cloud.com/ - description: server on the BIC security: diff --git a/api/sample.yaml b/api/sample.yaml index acdd33c..17df4c3 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -2,7 +2,7 @@ get: summary: all samples in overview description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: returns only samples with status 10 + x-doc: 'Limitations: paging and csv output does not work when including the spectrum measurement fields as well as the returned number of total samples' tags: - /sample parameters: @@ -61,6 +61,12 @@ responses: 200: description: samples overview (if the csv parameter is set, this is in CSV instead of JSON format) + headers: + X-Total-Items: + description: Total number of available items when page is specified + schema: + type: integer + example: 243 content: application/json: schema: diff --git a/data_import/import.js b/data_import/import.js index 4f31f8b..627e1b8 100644 --- a/data_import/import.js +++ b/data_import/import.js @@ -1,55 +1,82 @@ const csv = require('csv-parser'); const fs = require('fs'); const axios = require('axios'); -const {Builder} = require('selenium-webdriver'); +const {Builder} = require('selenium-webdriver'); // selenium and the chrome driver must be installed and configured separately const chrome = require('selenium-webdriver/chrome'); const pdfReader = require('pdfreader'); const iconv = require('iconv-lite'); -const metadata = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200622\\VZ.csv'; // metadata file -const nmDocs = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200622\\nmDocs'; // NormMaster Documents -const dptFiles = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200622\\DPT'; // Spectrum files +const metaDoc = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\metadata.csv'; // metadata files +const kfDoc = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\kf.csv'; +const vzDoc = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\vz.csv'; +const nmDocs = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\nmDocs'; // NormMaster Documents +const dptFiles = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\DPT'; // Spectrum files +// const host = 'http://localhost:3000'; +const host = 'https://definma-api.apps.de1.bosch-iot-cloud.com'; let data = []; // metadata contents let materials = {}; let samples = []; let normMaster = {}; +let sampleDevices = {}; -// TODO: integrate measurement device information from DPT names using different users -// TODO: supplier: other for supplierless samples // TODO: BASF twice, BASF as color -// TODO: trim color names // TODO: duplicate kf values +// TODO: conditions +// TODO: comment and reference handling + + +// TODO: check last color errors (filter out already taken) use location and device for user, upload to BIC main(); async function main() { if (0) { // materials await getNormMaster(); - await importCsv(); + await importCsv(metaDoc); + await allMaterials(); + await saveMaterials(); + await importCsv(kfDoc); + await allMaterials(); + await saveMaterials(); + await importCsv(vzDoc); await allMaterials(); - fs.writeFileSync('./data_import/materials.json', JSON.stringify(materials)); await saveMaterials(); } - else if (0) { // samples - await importCsv(); - await allSamples(); - await saveSamples(); + if (0) { // samples + sampleDeviceMap(); + if (1) { + console.log('-------- META ----------'); + await importCsv(metaDoc); + await allSamples(); + await saveSamples(); + } + if (1) { + console.log('-------- KF ----------'); + await importCsv(kfDoc); + await allSamples(); + await saveSamples(); + await allKfVz(); + } + if (1) { + console.log('-------- VZ ----------'); + await importCsv(vzDoc); + await allSamples(); + await saveSamples(); + await allKfVz(); + } } - else if (1) { // DPT + if (1) { // DPT await allDpts(); } - else if (0) { // KF/VZ - await importCsv(); - await allKfVz(); - } - else if (0) { // pdf test - console.log(await readPdf('N28_BN22-O010_2018-03-08.pdf')); + if (0) { // pdf test + console.log(await readPdf('N28_BN05-OX013_2016-03-11.pdf')); } } -async function importCsv() { +async function importCsv(doc) { + data = []; await new Promise(resolve => { - fs.createReadStream(metadata) + fs.createReadStream(doc) .pipe(iconv.decodeStream('win1252')) .pipe(csv()) .on('data', (row) => { @@ -57,6 +84,9 @@ async function importCsv() { }) .on('end', () => { console.info('CSV file successfully processed'); + if (data[0]['Farbe']) { // fix German column names + data.map(e => {e['Color'] = e['Farbe']; return e; }); + } resolve(); }); }); @@ -65,7 +95,7 @@ async function importCsv() { async function allDpts() { let res = await axios({ method: 'get', - url: 'http://localhost:3000/template/measurements', + url: host + '/template/measurements', auth: { username: 'admin', password: 'Abc123!#' @@ -74,7 +104,7 @@ async function allDpts() { const measurement_template = res.data.find(e => e.name === 'spectrum')._id; res = await axios({ method: 'get', - url: 'http://localhost:3000/samples?status=all', + url: host + '/samples?status=all', auth: { username: 'admin', password: 'Abc123!#' @@ -84,10 +114,10 @@ async function allDpts() { res.data.forEach(sample => { sampleIds[sample.number] = sample._id; }); - const regex = /.*?_(.*?)_(\d+|\d+_\d+).DPT/; + const dptRegex = /.*?_(.*?)_(\d+|\d+_\d+).DPT/; const dpts = fs.readdirSync(dptFiles); for (let i in dpts) { - const regexRes = regex.exec(dpts[i]) + const regexRes = dptRegex.exec(dpts[i]) if (regexRes && sampleIds[regexRes[1]]) { // found matching sample console.log(dpts[i]); const f = fs.readFileSync(dptFiles + '\\' + dpts[i], 'utf-8'); @@ -99,7 +129,7 @@ async function allDpts() { data.values.dpt = f.split('\r\n').map(e => e.split(',')); await axios({ method: 'post', - url: 'http://localhost:3000/measurement/new', + url: host + '/measurement/new', auth: { username: 'admin', password: 'Abc123!#' @@ -110,13 +140,16 @@ async function allDpts() { console.error(err.response.data); }); } + else { + console.log(`Could not find sample for ${dpts[i]} !!!!!!`); + } } } async function allKfVz() { let res = await axios({ method: 'get', - url: 'http://localhost:3000/template/measurements', + url: host + '/template/measurements', auth: { username: 'admin', password: 'Abc123!#' @@ -126,7 +159,7 @@ async function allKfVz() { const vz_template = res.data.find(e => e.name === 'vz')._id; res = await axios({ method: 'get', - url: 'http://localhost:3000/samples?status=all', + url: host + '/samples?status=all', auth: { username: 'admin', password: 'Abc123!#' @@ -140,13 +173,17 @@ async function allKfVz() { console.info(`${index}/${data.length}`); let sample = data[index]; if (sample['Sample number'] !== '') { + let credentials = ['admin', 'Abc123!#']; + if (sampleDevices[sample['Sample number']]) { + credentials = [sampleDevices[sample['Sample number']], '2020DeFinMachen!'] + } if (sample['KF in Gew%']) { await axios({ method: 'post', - url: 'http://localhost:3000/measurement/new', + url: host + '/measurement/new', auth: { - username: 'admin', - password: 'Abc123!#' + username: credentials[0], + password: credentials[1] }, data: { sample_id: sampleIds[sample['Sample number']], @@ -164,10 +201,10 @@ async function allKfVz() { if (sample['VZ (ml/g)']) { await axios({ method: 'post', - url: 'http://localhost:3000/measurement/new', + url: host + '/measurement/new', auth: { - username: 'admin', - password: 'Abc123!#' + username: credentials[0], + password: credentials[1] }, data: { sample_id: sampleIds[sample['Sample number']], @@ -186,9 +223,10 @@ async function allKfVz() { } async function allSamples() { + samples = []; let res = await axios({ method: 'get', - url: 'http://localhost:3000/materials?status=all', + url: host + '/materials?status=all', auth: { username: 'admin', password: 'Abc123!#' @@ -200,7 +238,7 @@ async function allSamples() { }) res = await axios({ method: 'get', - url: 'http://localhost:3000/samples?status=all', + url: host + '/samples?status=all', auth: { username: 'admin', password: 'Abc123!#' @@ -215,7 +253,13 @@ async function allSamples() { for (let index in data) { console.info(`${index}/${data.length}`); let sample = data[index]; - if (sample['Sample number'] !== '' && sample['Supplier'] !== '' && sample['Granulate/Part'] !== '') { // TODO: wait for decision about samples without suppliers/color/type + if (sample['Sample number'] !== '') { // TODO: what about samples without color + if (sample['Supplier'] === '') { // empty supplier fields + sample['Supplier'] = 'unknown'; + } + if (sample['Granulate/Part'] === '') { // empty supplier fields + sample['Granulate/Part'] = 'unknown'; + } const material = dbMaterials[trim(sample['Material name'])]; if (!material) { // could not find material, skipping sample continue; @@ -236,13 +280,20 @@ async function allSamples() { samples[si].color = material.numbers.find(e => e.number === sample['Material number']).color; } else if (sample['Color'] && sample['Color'] !== '') { - samples[si].color = material.numbers.find(e => e.color.indexOf(sample['Color']) >= 0).color; + let number = material.numbers.find(e => e.color.indexOf(trim(sample['Color'])) >= 0); + if (!number && /black/.test(sample['Color'])) { // special case bk for black + number = material.numbers.find(e => e.color.toLowerCase().indexOf('bk') >= 0); + if (!number) { // try German word + number = material.numbers.find(e => e.color.toLowerCase().indexOf('schwarz') >= 0); + } + } + samples[si].color = number.color; } else if (sampleColors[sample['Sample number'].split('_')[0]]) { // derive color from main sample for kf/vz samples[si].color = sampleColors[sample['Sample number'].split('_')[0]]; } - else { // TODO: no color information at all - samples.pop(); + else { + samples[si].color = ''; } } } @@ -251,41 +302,57 @@ async function allSamples() { async function saveSamples() { for (let i in samples) { console.info(`${i}/${samples.length}`); + let credentials = ['admin', 'Abc123!#']; + if (sampleDevices[samples[i].number]) { + credentials = [sampleDevices[samples[i].number], '2020DeFinMachen!'] + } await axios({ method: 'post', - url: 'http://localhost:3000/sample/new', + url: host + '/sample/new', auth: { - username: 'admin', - password: 'Abc123!#' + username: credentials[0], + password: credentials[1] }, data: samples[i] }).catch(err => { - console.log(samples[i]); - console.error(err.response.data); + if (err.response.data.status && err.response.data.status !== 'Sample number already taken') { + console.log(samples[i]); + console.error(err.response.data); + } }); } console.info('saved all samples'); } async function allMaterials() { + materials = {}; for (let index in data) { let sample = data[index]; - if (sample['Sample number'] !== '' && sample['Supplier'] !== '') { // TODO: wait for decision about supplierless samples + if (sample['Sample number'] && sample['Sample number'] !== '') { + if (sample['Supplier'] === '') { // empty supplier fields + sample['Supplier'] = 'unknown'; + } + if (sample['Material name'] === '') { // empty name fields + sample['Material name'] = sample['Material']; + } + if (!sample['Material']) { // column Material is named Plastic in VZ metadata + sample['Material'] = sample['Plastic']; + } sample['Material name'] = trim(sample['Material name']); if (materials.hasOwnProperty(sample['Material name'])) { // material already found at least once - if (sample['Material number'] !== '') { + if (sample['Material number'] && sample['Material number'] !== '') { if (materials[sample['Material name']].numbers.length === 0 || !materials[sample['Material name']].numbers.find(e => e.number === stripSpaces(sample['Material number']))) { // new material number if (materials[sample['Material name']].numbers.find(e => e.color === sample['Color'] && e.number === '')) { // color already in list, only number missing materials[sample['Material name']].numbers.find(e => e.color === sample['Color'] && e.number === '').number = stripSpaces(sample['Material number']); } else { - materials[sample['Material name']].numbers.push({color: sample['Color'], number: stripSpaces(sample['Material number'])}); + materials[sample['Material name']].numbers.push({color: trim(sample['Color']), number: stripSpaces(sample['Material number'])}); } } } - else if (sample['Color'] !== '') { + else if (sample['Color'] && sample['Color'] !== '') { if (!materials[sample['Material name']].numbers.find(e => e.color === stripSpaces(sample['Color']))) { // new material color - materials[sample['Material name']].numbers.push({color: sample['Color'], number: ''}); + materials[sample['Material name']].numbers.push({color: trim(sample['Color']), number: ''}); } } } @@ -293,8 +360,8 @@ async function allMaterials() { console.info(`${index}/${data.length} ${sample['Material name']}`); materials[sample['Material name']] = { name: sample['Material name'], - supplier: sample['Supplier'], - group: sample['Material'] + supplier: trim(sample['Supplier']), + group: trim(sample['Material']) }; let tmp = /M(\d+)/.exec(sample['Reinforcing material']); materials[sample['Material name']].mineral = tmp ? tmp[1] : 0; @@ -312,17 +379,20 @@ async function allMaterials() { async function saveMaterials() { const mKeys = Object.keys(materials) for (let i in mKeys) { + console.info(`${i}/${mKeys.length}`); await axios({ method: 'post', - url: 'http://localhost:3000/material/new', + url: host + '/material/new', auth: { username: 'admin', password: 'Abc123!#' }, data: materials[mKeys[i]] }).catch(err => { - console.log(materials[mKeys[i]]); - console.error(err.response.data); + if (err.response.data.status && err.response.data.status !== 'Material name already taken') { + console.info(materials[mKeys[i]]); + console.error(err.response.data); + } }); } console.info('saved all materials'); @@ -362,16 +432,16 @@ async function numbersFetch(sample) { } } if (res.length === 0) { // no results - if (sample['Color'] !== '' || sample['Material number'] !== '') { - return [{color: sample['Color'], number: sample['Material number']}]; + if ((sample['Color'] && sample['Color'] !== '') || (sample['Material number'] &&sample['Material number'] !== '')) { + return [{color: trim(sample['Color']), number: sample['Material number']}]; } else { return []; } } else { - if (!res.find(e => e.number === sample['Material number'])) { // sometimes norm master does not include sample number even if listed - res.push({color: sample['Color'], number: sample['Material number']}); + if (sample['Material number'] && !res.find(e => e.number === sample['Material number'])) { // sometimes norm master does not include sample number even if listed + res.push({color: trim(sample['Color']), number: sample['Material number']}); } return res; } @@ -403,7 +473,7 @@ async function getNormMaster(fetchAgain = false) { } function getNormMasterDoc(url, timing = 1) { - console.log(url); + console.info(url); return new Promise(async resolve => { const options = new chrome.Options(); options.setUserPreferences({ @@ -453,7 +523,7 @@ function readPdf(file) { rows.push(item.text); } else { // still the same row row - rows[rows.length - 1] += (item.x - lastX > 1.1 ? '$' : '') + item.text; // push to row, detect if still same cell + rows[rows.length - 1] += (item.x - lastX > 1.09 ? '$' : '') + item.text; // push to row, detect if still same cell } lastX = (item.w * 0.055) + item.x; @@ -465,7 +535,7 @@ function readPdf(file) { table = -1; // console.log(rows); rows = rows.filter(e => /^\d{10}/m.test(stripSpaces(e))); // filter non-table rows - resolve(rows.map(e => {return {color: e.split('$')[3], number: stripSpaces(e.split('$')[0])}; })); + resolve(rows.map(e => {return {color: trim(e.split('$')[3]), number: stripSpaces(e.split('$')[0])}; })); } } lastLastText = lastText; @@ -473,12 +543,23 @@ function readPdf(file) { } if (!item && table !== -1) { // document ended rows = rows.filter(e => /^\d{10}/m.test(stripSpaces(e))); // filter non-table rows - resolve(rows.map(e => {return {color: e.split('$')[3], number: stripSpaces(e.split('$')[0])}; })); + resolve(rows.map(e => {return {color: trim(e.split('$')[3]), number: stripSpaces(e.split('$')[0])}; })); } }); }); } +function sampleDeviceMap() { + const dpts = fs.readdirSync(dptFiles); + const regex = /(.*?)_(.*?)_(\d+|[^_]+_\d+).DPT/; + for (let i in dpts) { + const regexRes = regex.exec(dpts[i]) + if (regexRes) { // found matching sample + sampleDevices[regexRes[2]] = regexRes[1] === 'plastics' ? 'rng01' : regexRes[1].toLowerCase(); + } + } +} + function stripSpaces(s) { return s ? s.replace(/ /g,'') : ''; } diff --git a/mainfest.yml b/manifest.yml similarity index 75% rename from mainfest.yml rename to manifest.yml index 16e5924..0e8c57d 100644 --- a/mainfest.yml +++ b/manifest.yml @@ -1,6 +1,7 @@ --- applications: - - name: digital-fingerprint-of-plastics-api + - name: definma-api + path: dist/ instances: 1 memory: 256M stack: cflinuxfs3 @@ -10,4 +11,4 @@ applications: NODE_ENV: production OPTIMIZE_MEMORY: true services: - - dfopdb + - definmadb diff --git a/package-lock.json b/package-lock.json index 5478eef..34fb53e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1174,6 +1174,15 @@ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2866,6 +2875,11 @@ } } }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, "object-inspect": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", diff --git a/package.json b/package.json index 4b04218..f9494d3 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "tsc": "tsc", "tsc-full": "del /q dist\\* & (for /d %x in (dist\\*) do @rd /s /q \"%x\") & tsc", "build": "build.bat", + "build-push": "build.bat && cf push", "test": "mocha dist/**/**.spec.js", "start": "node index.js", "dev": "nodemon -e ts,yaml --exec \"tsc && node dist/index.js || exit 1\"", @@ -28,6 +29,7 @@ "cfenv": "^1.2.2", "compression": "^1.7.4", "content-filter": "^1.1.2", + "cors": "^2.8.5", "express": "^4.17.1", "helmet": "^3.22.0", "json-schema": "^0.2.5", @@ -35,7 +37,7 @@ "lodash": "^4.17.15", "mongo-sanitize": "^1.1.0", "mongoose": "^5.8.7", - "swagger-ui-express": "^4.1.2" + "swagger-ui-express": "4.1.2" }, "devDependencies": { "@types/bcrypt": "^3.0.0", diff --git a/src/api.ts b/src/api.ts index 0867bc1..aab7b80 100644 --- a/src/api.ts +++ b/src/api.ts @@ -18,6 +18,7 @@ export default class api { jsonRefParser.bundle('api/api.yaml', (err, doc) => { // parse yaml if (err) throw err; apiDoc = doc; + apiDoc.servers.splice(process.env.NODE_ENV === 'production', 1); apiDoc.paths = apiDoc.paths.allOf.reduce((s, e) => Object.assign(s, e)); // bundle routes apiDoc = this.resolveXDoc(apiDoc); oasParser.validate(apiDoc, (err, api) => { // validate oas schema diff --git a/src/helpers/csv.ts b/src/helpers/csv.ts index 18e633c..38c487a 100644 --- a/src/helpers/csv.ts +++ b/src/helpers/csv.ts @@ -1,12 +1,6 @@ import {parseAsync} from 'json2csv'; export default function csv(input: any[], f: (err, data) => void) { - console.log(input[1000]); - console.log(flatten(input[1000])); - parseAsync([flatten(input[1000])]).then(csv => console.log(csv)); - console.log(input[1]); - console.log(flatten(input[1])); - parseAsync([flatten(input[1])]).then(csv => console.log(csv)); parseAsync(input.map(e => flatten(e)), {includeEmptyRows: true}) .then(csv => f(null, csv)) .catch(err => f(err, null)); diff --git a/src/helpers/mail.ts b/src/helpers/mail.ts index a3d79c1..8ec71c8 100644 --- a/src/helpers/mail.ts +++ b/src/helpers/mail.ts @@ -17,7 +17,7 @@ export default (mailAddress, subject, content, f) => { // callback, executed em contentType: "text/html" }, from: { - eMail: "dfop@bosch-iot.com", + eMail: "definma@bosch-iot.com", password: "PlasticsOfFingerprintDigital" } } diff --git a/src/index.ts b/src/index.ts index 8116de7..4051f23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import compression from 'compression'; import contentFilter from 'content-filter'; import mongoSanitize from 'mongo-sanitize'; import helmet from 'helmet'; +import cors from 'cors'; import api from './api'; import db from './db'; @@ -42,9 +43,11 @@ app.use((req, res, next) => { // no database connection error next(); } else { + console.error('No database connection'); res.status(500).send({status: 'Internal server error'}); } }); +app.use(cors()); // CORS headers app.use(require('./helpers/authorize')); // handle authentication // redirect /api routes for Angular proxy in development diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index d15200b..7dc5f24 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -21,6 +21,7 @@ describe('/sample', () => { // TODO: sort, added date filter, has measurements/condition filter // TODO: check if conditions work in sort/fields/filters + // TODO: test for numbers as strings in glass_fiber describe('GET /samples', () => { it('returns all samples', done => { TestHelper.request(server, done, { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 11080f7..bf741c2 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -21,6 +21,12 @@ import csv from '../helpers/csv'; const router = express.Router(); +// TODO: check added filter +// TODO: return total number of pages -> use facet +// TODO: use query pointer +// TODO: convert filter value to number according to table model +// TODO: validation for filter parameters +// TODO: location/device sort/filter router.get('/samples', async (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; @@ -37,6 +43,7 @@ router.get('/samples', async (req, res, next) => { if (!filters['to-page']) { // set to-page default filters['to-page'] = 0; } + console.log(filters); const sortFilterKeys = filters.filters.map(e => e.field); @@ -70,7 +77,7 @@ router.get('/samples', async (req, res, next) => { {$replaceRoot: {newRoot: {measurement: '$$ROOT'}}}, // fetch samples and restructure them to fit sample structure {$lookup: {from: 'samples', localField: 'measurement.sample_id', foreignField: '_id', as: 'sample'}}, {$match: statusQuery(filters, 'sample.status')}, // filter out wrong status once samples were added - {$set: {['sample.' + measurementName]: '$measurement.values'}}, // more restructuring + {$addFields: {['sample.' + measurementName]: '$measurement.values'}}, // more restructuring {$replaceRoot: {newRoot: {$mergeObjects: [{$arrayElemAt: ['$sample', 0]}, {}]}}} ); addFilterQueries(query, filters.filters.filter(e => sampleKeys.indexOf(e.field) >= 0)); // sample filters @@ -106,25 +113,25 @@ router.get('/samples', async (req, res, next) => { materialAdded = true; materialQuery.push( // add material properties {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, // TODO: project out unnecessary fields - {$set: {material: {$arrayElemAt: ['$material', 0]}}} + {$addFields: {material: {$arrayElemAt: ['$material', 0]}}} ); const baseMFilters = sortFilterKeys.filter(e => /material\./.test(e)).filter(e => ['material.supplier', 'material.group', 'material.number'].indexOf(e) < 0); addFilterQueries(materialQuery, filters.filters.filter(e => baseMFilters.indexOf(e.field) >= 0)); // base material filters if (sortFilterKeys.find(e => e === 'material.supplier')) { // add supplier if needed materialQuery.push( {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, - {$set: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} + {$addFields: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} ); } if (sortFilterKeys.find(e => e === 'material.group')) { // add group if needed materialQuery.push( {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, - {$set: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}} + {$addFields: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}} ); } if (sortFilterKeys.find(e => e === 'material.number')) { // add material number if needed materialQuery.push( - {$set: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}} + {$addFields: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}} ); } const specialMFilters = sortFilterKeys.filter(e => /material\./.test(e)).filter(e => ['material.supplier', 'material.group', 'material.number'].indexOf(e) >= 0); @@ -157,10 +164,10 @@ router.get('/samples', async (req, res, next) => { as: 'measurements' }}); measurementTemplates.forEach(template => { - query.push({$set: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values + query.push({$addFields: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}}, in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']} - }}}}, {$set: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', template.parameters.reduce((s, e) => {s[e.name] = null; return s;}, {})]}}}); + }}}}, {$addFields: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', template.parameters.reduce((s, e) => {s[e.name] = null; return s;}, {})]}}}); }); addFilterQueries(query, filters.filters .filter(e => sortFilterKeys.filter(e => /measurements\./.test(e)).indexOf(e.field) >= 0) @@ -173,39 +180,40 @@ router.get('/samples', async (req, res, next) => { sortFilterKeys.indexOf(e) < 0 // field was not in filter && e !== filters.sort[0] // field was not in sort ); + console.log(fieldsToAdd); if (fieldsToAdd.find(e => /material\./.test(e)) && !materialAdded) { // add material, was not added already query.push( {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, - {$set: {material: { $arrayElemAt: ['$material', 0]}}} + {$addFields: {material: { $arrayElemAt: ['$material', 0]}}} ); } if (fieldsToAdd.indexOf('material.supplier') >= 0) { // add supplier if needed query.push( {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, - {$set: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} + {$addFields: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} ); } if (fieldsToAdd.indexOf('material.group') >= 0) { // add group if needed query.push( {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, - {$set: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}} + {$addFields: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}} ); } if (fieldsToAdd.indexOf('material.number') >= 0) { // add material number if needed query.push( - {$set: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}} + {$addFields: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}} ); } - let measurementFieldsFields = _.uniq(fieldsToAdd.filter(e => /measurements\./.test(e)).map(e => e.split('.')[1])); // filter measurement names and remove duplicates from parameters + let measurementFieldsFields: string[] = _.uniq(fieldsToAdd.filter(e => /measurements\./.test(e)).map(e => e.split('.')[1])); // filter measurement names and remove duplicates from parameters if (fieldsToAdd.find(e => /measurements\./.test(e))) { // add measurement fields const measurementTemplates = await MeasurementTemplateModel.find({name: {$in: measurementFieldsFields}}).lean().exec().catch(err => {next(err);}); if (measurementTemplates instanceof Error) return; if (measurementTemplates.length < measurementFieldsFields.length) { return res.status(400).json({status: 'Invalid body format', details: 'Measurement key not found'}); } - if (fieldsToAdd.find(e => e === 'measurements.spectrum')) { // use different lookup methods with and without spectrum for the best performance + if (fieldsToAdd.find(e => /spectrum\./.test(e))) { // use different lookup methods with and without spectrum for the best performance query.push({$lookup: {from: 'measurements', localField: '_id', foreignField: 'sample_id', as: 'measurements'}}); } else { @@ -216,15 +224,15 @@ router.get('/samples', async (req, res, next) => { }}); } measurementTemplates.filter(e => e.name !== 'spectrum').forEach(template => { // TODO: hard coded dpt for special treatment, change later - query.push({$set: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values + query.push({$addFields: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}}, in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']} - }}}}, {$set: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', template.parameters.reduce((s, e) => {s[e.name] = null; return s;}, {})]}}}); + }}}}, {$addFields: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', template.parameters.reduce((s, e) => {s[e.name] = null; return s;}, {})]}}}); }); if (measurementFieldsFields.find(e => e === 'spectrum')) { // TODO: remove hardcoded as well query.push( - {$set: {spectrum: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', measurementTemplates.filter(e => e.name === 'spectrum')[0]._id]}}}}}, - {$set: {spectrum: '$spectrum.values.dpt'}}, + {$addFields: {spectrum: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', measurementTemplates.filter(e => e.name === 'spectrum')[0]._id]}}}}}, + {$addFields: {spectrum: '$spectrum.values'}}, {$unwind: '$spectrum'} ); } @@ -233,30 +241,45 @@ router.get('/samples', async (req, res, next) => { const projection = filters.fields.map(e => e.replace('measurements.', '')).reduce((s, e) => {s[e] = true; return s; }, {}); if (filters.fields.indexOf('added') >= 0) { // add added date - projection.added = {$toDate: '$_id'}; + // projection.added = {$toDate: '$_id'}; + // projection.added = { $convert: { input: '$_id', to: "date" } } // TODO } if (!(filters.fields.indexOf('_id') >= 0)) { // disable _id explicitly projection._id = false; } query.push({$project: projection}); - collection.aggregate(query).exec((err, data) => { - if (err) return next(err); - if (filters['to-page'] < 0) { - data.reverse(); - } - const measurementFields = _.uniq([...measurementFilterFields, ...measurementFieldsFields]); - if (filters.csv) { // output as csv - csv(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields))), (err, data) => { - if (err) return next(err); - res.set('Content-Type', 'text/csv'); - res.send(data); - }); - } - else { - res.json(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields)))); // validate all and filter null values from validation errors - } - }) + if (!fieldsToAdd.find(e => /spectrum\./.test(e))) { // use streaming when including spectrum files + collection.aggregate(query).exec((err, data) => { + if (err) return next(err); + console.log(data.length); + if (filters['to-page'] < 0) { + data.reverse(); + } + const measurementFields = _.uniq([filters.sort[0].split('.')[1], ...measurementFilterFields, ...measurementFieldsFields]); + if (filters.csv) { // output as csv + csv(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields))), (err, data) => { + if (err) return next(err); + res.set('Content-Type', 'text/csv'); + res.send(data); + }); + } + else { + res.json(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields)))); // validate all and filter null values from validation errors + } + }); + } + else { + res.writeHead(200, {'Content-Type': 'application/json; charset=utf-8'}); + res.write('['); + let count = 0; + const stream = collection.aggregate(query).cursor().exec(); + stream.on('data', data => { res.write((count === 0 ? '' : ',\n') + JSON.stringify(data)); count ++; }); + stream.on('close', () => { + res.write(']'); + res.end(); + }); + } }); router.get('/samples/:state(new|deleted)', (req, res, next) => { @@ -537,7 +560,7 @@ async function materialCheck (sample, res, next, id = sample.material_id) { // res.status(400).json({status: 'Material not available'}); return false; } - if (sample.hasOwnProperty('color') && !materialData.numbers.find(e => e.color === sample.color)) { // color for material not specified + if (sample.hasOwnProperty('color') && sample.color !== '' && !materialData.numbers.find(e => e.color === sample.color)) { // color for material not specified res.status(400).json({status: 'Color not available for material'}); return false; } diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index b0cae01..f84a5be 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -11,7 +11,8 @@ export default class SampleValidate { .max(128), color: Joi.string() - .max(128), + .max(128) + .allow(''), type: Joi.string() .max(128), @@ -77,7 +78,7 @@ export default class SampleValidate { 'user_id', 'material._id', 'material.numbers', - 'measurements.spectrum' + 'measurements.spectrum.dpt' ]; static input (data, param) { // validate input, set param to 'new' to make all attributes required @@ -170,6 +171,33 @@ export default class SampleValidate { try { for (let i in data.filters) { data.filters[i] = JSON.parse(data.filters[i]); + data.filters[i].values = data.filters[i].values.map(e => { // validate filter values + let validator; + let field = data.filters[i].field + if (/material\./.test(field)) { // select right validation model + validator = MaterialValidate.outputV(); + field = field.replace('material.', ''); + } + else if (/measurements\./.test(field)) { + validator = Joi.object({ + value: Joi.alternatives() + .try( + Joi.string().max(128), + Joi.number(), + Joi.boolean(), + Joi.array() + ) + .allow(null) + }); + field = 'value'; + } + else { + validator = Joi.object(this.sample); + } + const {value, error} = validator.validate({[field]: e}); + if (error) throw error; // reject invalid values + return value[field]; + }); } } catch { From f41498da53a274dda6ebe64beb951a7ea40b10bf Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 9 Jul 2020 16:30:10 +0200 Subject: [PATCH 80/83] implemented x-total-items header --- api/sample.yaml | 4 +- manifest.yml | 2 +- package.json | 2 +- src/db.ts | 6 ++- src/index.ts | 2 +- src/routes/sample.ts | 89 +++++++++++++++++++++++--------------------- 6 files changed, 57 insertions(+), 48 deletions(-) diff --git a/api/sample.yaml b/api/sample.yaml index 17df4c3..2b0ce31 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -62,8 +62,8 @@ 200: description: samples overview (if the csv parameter is set, this is in CSV instead of JSON format) headers: - X-Total-Items: - description: Total number of available items when page is specified + x-total-items: + description: Total number of available items when from-id is not specified and spectrum field is not included schema: type: integer example: 243 diff --git a/manifest.yml b/manifest.yml index 0e8c57d..dd7e0f1 100644 --- a/manifest.yml +++ b/manifest.yml @@ -3,7 +3,7 @@ applications: - name: definma-api path: dist/ instances: 1 - memory: 256M + memory: 512M stack: cflinuxfs3 buildpacks: - nodejs_buildpack diff --git a/package.json b/package.json index f9494d3..bfcdcca 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "build.bat", "build-push": "build.bat && cf push", "test": "mocha dist/**/**.spec.js", - "start": "node index.js", + "start": "sleep 5s && node index.js", "dev": "nodemon -e ts,yaml --exec \"tsc && node dist/index.js || exit 1\"", "loadDev": "node dist/test/loadDev.js", "coverage": "tsc && nyc --reporter=html --reporter=text mocha dist/**/**.spec.js --timeout 5000", diff --git a/src/db.ts b/src/db.ts index cb11af5..03c56e1 100644 --- a/src/db.ts +++ b/src/db.ts @@ -3,11 +3,15 @@ import cfenv from 'cfenv'; import _ from 'lodash'; import ChangelogModel from './models/changelog'; -mongoose.set('debug', true); // enable mongoose debug // database urls, prod db url is retrieved automatically const TESTING_URL = 'mongodb://localhost/dfopdb_test'; const DEV_URL = 'mongodb://localhost/dfopdb'; +const debugging = false; + +if (process.env.NODE_ENV !== 'production' && debugging) { + mongoose.set('debug', true); // enable mongoose debug +} export default class db { private static state = { // db object and current mode (test, dev, prod) diff --git a/src/index.ts b/src/index.ts index 4051f23..d6ea865 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,11 +26,11 @@ const port = process.env.PORT || 3000; //middleware app.use(helmet()); +app.use(contentFilter()); // filter URL query attacks app.use(express.json({ limit: '5mb'})); app.use(express.urlencoded({ extended: false, limit: '5mb' })); app.use(compression()); // compress responses app.use(bodyParser.json()); -app.use(contentFilter()); // filter URL query attacks app.use((req, res, next) => { // filter body query attacks req.body = mongoSanitize(req.body); next(); diff --git a/src/routes/sample.ts b/src/routes/sample.ts index bf741c2..f63b0b6 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -43,13 +43,13 @@ router.get('/samples', async (req, res, next) => { if (!filters['to-page']) { // set to-page default filters['to-page'] = 0; } - console.log(filters); const sortFilterKeys = filters.filters.map(e => e.field); let collection; const query = []; - query.push({$match: {$and: []}}); + let queryPtr = query; + queryPtr.push({$match: {$and: []}}); if (filters.sort[0].indexOf('measurements.') >= 0) { // sorting with measurements as starting collection collection = MeasurementModel; @@ -68,23 +68,23 @@ router.get('/samples', async (req, res, next) => { } sortStartValue = fromSample.values[measurementParam]; } - query[0].$match.$and.push({measurement_template: mongoose.Types.ObjectId(measurementTemplate._id)}); // find measurements to sort + queryPtr[0].$match.$and.push({measurement_template: mongoose.Types.ObjectId(measurementTemplate._id)}); // find measurements to sort if (filters.filters.find(e => e.field === filters.sort[0])) { // sorted measurement should also be filtered - query[0].$match.$and.push(...filterQueries(filters.filters.find(e => e.field === filters.sort[0]))); + queryPtr[0].$match.$and.push(...filterQueries(filters.filters.find(e => e.field === filters.sort[0]))); } - query.push( - ...sortQuery(query, filters, ['values.' + measurementParam, 'sample_id'], sortStartValue), // sort measurements + queryPtr.push( + ...sortQuery(filters, ['values.' + measurementParam, 'sample_id'], sortStartValue), // sort measurements {$replaceRoot: {newRoot: {measurement: '$$ROOT'}}}, // fetch samples and restructure them to fit sample structure {$lookup: {from: 'samples', localField: 'measurement.sample_id', foreignField: '_id', as: 'sample'}}, {$match: statusQuery(filters, 'sample.status')}, // filter out wrong status once samples were added {$addFields: {['sample.' + measurementName]: '$measurement.values'}}, // more restructuring {$replaceRoot: {newRoot: {$mergeObjects: [{$arrayElemAt: ['$sample', 0]}, {}]}}} ); - addFilterQueries(query, filters.filters.filter(e => sampleKeys.indexOf(e.field) >= 0)); // sample filters + addFilterQueries(queryPtr, filters.filters.filter(e => sampleKeys.indexOf(e.field) >= 0)); // sample filters } else { // sorting with samples as starting collection collection = SampleModel; - query[0].$match.$and.push(statusQuery(filters, 'status')); + queryPtr[0].$match.$and.push(statusQuery(filters, 'status')); if (sampleKeys.indexOf(filters.sort[0]) >= 0) { // sorting for sample keys let sortStartValue = null; @@ -98,14 +98,14 @@ router.get('/samples', async (req, res, next) => { } sortStartValue = fromSample[filters.sort[0]]; } - query.push(...sortQuery(query, filters, [filters.sort[0], '_id'], sortStartValue)); + queryPtr.push(...sortQuery(filters, [filters.sort[0], '_id'], sortStartValue)); } else { // add sort key to list to add field later sortFilterKeys.push(filters.sort[0]); } } - addFilterQueries(query, filters.filters.filter(e => sampleKeys.indexOf(e.field) >= 0)); // sample filters + addFilterQueries(queryPtr, filters.filters.filter(e => sampleKeys.indexOf(e.field) >= 0)); // sample filters let materialQuery = []; // put material query together separate first to reuse for first-id let materialAdded = false; @@ -136,7 +136,7 @@ router.get('/samples', async (req, res, next) => { } const specialMFilters = sortFilterKeys.filter(e => /material\./.test(e)).filter(e => ['material.supplier', 'material.group', 'material.number'].indexOf(e) >= 0); addFilterQueries(materialQuery, filters.filters.filter(e => specialMFilters.indexOf(e.field) >= 0)); // base material filters - query.push(...materialQuery); + queryPtr.push(...materialQuery); if (/material\./.test(filters.sort[0])) { // sort by material key let sortStartValue = null; if (filters['from-id']) { // from-id specified @@ -147,7 +147,7 @@ router.get('/samples', async (req, res, next) => { } sortStartValue = fromSample[filters.sort[0]]; } - query.push(...sortQuery(query, filters, [filters.sort[0], '_id'], sortStartValue)); + queryPtr.push(...sortQuery(filters, [filters.sort[0], '_id'], sortStartValue)); } } @@ -158,50 +158,61 @@ router.get('/samples', async (req, res, next) => { if (measurementTemplates.length < measurementFilterFields.length) { return res.status(400).json({status: 'Invalid body format', details: 'Measurement key not found'}); } - query.push({$lookup: { + queryPtr.push({$lookup: { from: 'measurements', let: {sId: '$_id'}, pipeline: [{$match: {$expr: {$and: [{$eq: ['$sample_id', '$$sId']}, {$in: ['$measurement_template', measurementTemplates.map(e => mongoose.Types.ObjectId(e._id))]}]}}}], as: 'measurements' }}); measurementTemplates.forEach(template => { - query.push({$addFields: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values + queryPtr.push({$addFields: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}}, in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']} }}}}, {$addFields: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', template.parameters.reduce((s, e) => {s[e.name] = null; return s;}, {})]}}}); }); - addFilterQueries(query, filters.filters + addFilterQueries(queryPtr, filters.filters .filter(e => sortFilterKeys.filter(e => /measurements\./.test(e)).indexOf(e.field) >= 0) .map(e => {e.field = e.field.replace('measurements.', ''); return e; }) ); // measurement filters } - addSkipLimit(query, filters); + + if (!filters.fields.find(e => /spectrum\./.test(e)) && !filters['from-id']) { // count total number of items before $skip and $limit, only works when from-id is not specified and spectra are not included + queryPtr.push({$facet: {count: [{$count: 'count'}], samples: []}}); + queryPtr = queryPtr[queryPtr.length - 1].$facet.samples; // add rest of aggregation pipeline into $facet + } + + // paging + if (filters['to-page']) { + queryPtr.push({$skip: Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size'] + Number(filters['to-page'] < 0)}) // number to skip, if going back pages, one page has to be skipped less but on sample more + } + if (filters['page-size']) { + queryPtr.push({$limit: filters['page-size']}); + } const fieldsToAdd = filters.fields.filter(e => // fields to add sortFilterKeys.indexOf(e) < 0 // field was not in filter && e !== filters.sort[0] // field was not in sort ); - console.log(fieldsToAdd); if (fieldsToAdd.find(e => /material\./.test(e)) && !materialAdded) { // add material, was not added already - query.push( + queryPtr.push( {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, {$addFields: {material: { $arrayElemAt: ['$material', 0]}}} ); } if (fieldsToAdd.indexOf('material.supplier') >= 0) { // add supplier if needed - query.push( + queryPtr.push( {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}}, {$addFields: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}} ); } if (fieldsToAdd.indexOf('material.group') >= 0) { // add group if needed - query.push( + queryPtr.push( {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }}, {$addFields: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}} ); } if (fieldsToAdd.indexOf('material.number') >= 0) { // add material number if needed - query.push( + queryPtr.push( {$addFields: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}} ); } @@ -214,45 +225,49 @@ router.get('/samples', async (req, res, next) => { return res.status(400).json({status: 'Invalid body format', details: 'Measurement key not found'}); } if (fieldsToAdd.find(e => /spectrum\./.test(e))) { // use different lookup methods with and without spectrum for the best performance - query.push({$lookup: {from: 'measurements', localField: '_id', foreignField: 'sample_id', as: 'measurements'}}); + queryPtr.push({$lookup: {from: 'measurements', localField: '_id', foreignField: 'sample_id', as: 'measurements'}}); } else { - query.push({$lookup: { + queryPtr.push({$lookup: { from: 'measurements', let: {sId: '$_id'}, pipeline: [{$match: {$expr: {$and: [{$eq: ['$sample_id', '$$sId']}, {$in: ['$measurement_template', measurementTemplates.map(e => mongoose.Types.ObjectId(e._id))]}]}}}], as: 'measurements' }}); } measurementTemplates.filter(e => e.name !== 'spectrum').forEach(template => { // TODO: hard coded dpt for special treatment, change later - query.push({$addFields: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values + queryPtr.push({$addFields: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}}, in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']} }}}}, {$addFields: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', template.parameters.reduce((s, e) => {s[e.name] = null; return s;}, {})]}}}); }); if (measurementFieldsFields.find(e => e === 'spectrum')) { // TODO: remove hardcoded as well - query.push( + queryPtr.push( {$addFields: {spectrum: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', measurementTemplates.filter(e => e.name === 'spectrum')[0]._id]}}}}}, {$addFields: {spectrum: '$spectrum.values'}}, {$unwind: '$spectrum'} ); } - query.push({$unset: 'measurements'}); + // queryPtr.push({$unset: 'measurements'}); + queryPtr.push({$project: {measurements: 0}}); } const projection = filters.fields.map(e => e.replace('measurements.', '')).reduce((s, e) => {s[e] = true; return s; }, {}); if (filters.fields.indexOf('added') >= 0) { // add added date // projection.added = {$toDate: '$_id'}; - // projection.added = { $convert: { input: '$_id', to: "date" } } // TODO + // projection.added = { $convert: { input: '$_id', to: "date" } } // TODO: upgrade MongoDB version or find alternative } if (!(filters.fields.indexOf('_id') >= 0)) { // disable _id explicitly projection._id = false; } - query.push({$project: projection}); + queryPtr.push({$project: projection}); if (!fieldsToAdd.find(e => /spectrum\./.test(e))) { // use streaming when including spectrum files collection.aggregate(query).exec((err, data) => { if (err) return next(err); - console.log(data.length); + if (data[0].count) { + res.header('x-total-items', data[0].count.length > 0 ? data[0].count[0].count : 0); + data = data[0].samples; + } if (filters['to-page'] < 0) { data.reverse(); } @@ -639,7 +654,7 @@ function customFieldsChange (fields, amount, req) { // update custom_fields and }); } -function sortQuery(query, filters, sortKeys, sortStartValue) { // sortKeys = ['primary key', 'secondary key'] +function sortQuery(filters, sortKeys, sortStartValue) { // sortKeys = ['primary key', 'secondary key'] if (filters['from-id']) { // from-id specified if ((filters['to-page'] === 0 && filters.sort[1] === 1) || (filters.sort[1] * filters['to-page'] > 0)) { // asc return [{$match: {$or: [{[sortKeys[0]]: {$gt: sortStartValue}}, {$and: [{[sortKeys[0]]: sortStartValue}, {[sortKeys[1]]: {$gte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}}, @@ -653,16 +668,6 @@ function sortQuery(query, filters, sortKeys, sortStartValue) { // sortKeys = [' } } -function addSkipLimit(query, filters) { - if (filters['to-page']) { - query.push({$skip: Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size'] + Number(filters['to-page'] < 0)}) // number to skip, if going back pages, one page has to be skipped less but on sample more - } - - if (filters['page-size']) { - query.push({$limit: filters['page-size']}); - } -} - function statusQuery(filters, field) { if (filters.hasOwnProperty('status')) { if(filters.status === 'all') { @@ -677,9 +682,9 @@ function statusQuery(filters, field) { } } -function addFilterQueries (query, filters) { // returns array of match queries from given filters +function addFilterQueries (queryPtr, filters) { // returns array of match queries from given filters if (filters.length) { - query.push({$match: {$and: filterQueries(filters)}}); + queryPtr.push({$match: {$and: filterQueries(filters)}}); } } From 523b2c9b68fd3930759f76ba240e48e54ee98ddf Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 10 Jul 2020 09:42:05 +0200 Subject: [PATCH 81/83] added workaround for 'added' field compatible to MongoDB 3.6 --- manifest.yml | 2 +- package.json | 2 +- src/db.ts | 7 ++++++- src/routes/sample.ts | 22 ++++++++++++++++++++-- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/manifest.yml b/manifest.yml index dd7e0f1..1791c58 100644 --- a/manifest.yml +++ b/manifest.yml @@ -3,7 +3,7 @@ applications: - name: definma-api path: dist/ instances: 1 - memory: 512M + memory: 1024M stack: cflinuxfs3 buildpacks: - nodejs_buildpack diff --git a/package.json b/package.json index bfcdcca..f9494d3 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "build.bat", "build-push": "build.bat && cf push", "test": "mocha dist/**/**.spec.js", - "start": "sleep 5s && node index.js", + "start": "node index.js", "dev": "nodemon -e ts,yaml --exec \"tsc && node dist/index.js || exit 1\"", "loadDev": "node dist/test/loadDev.js", "coverage": "tsc && nyc --reporter=html --reporter=text mocha dist/**/**.spec.js --timeout 5000", diff --git a/src/db.ts b/src/db.ts index 03c56e1..2b1f409 100644 --- a/src/db.ts +++ b/src/db.ts @@ -47,10 +47,15 @@ export default class db { if (err) done(err); }); mongoose.connection.on('error', console.error.bind(console, 'connection error:')); + mongoose.connection.on('connected', () => { // evaluation connection behaviour on prod + if (process.env.NODE_ENV !== 'test') { // Do not interfere with testing + console.info('Database connected'); + } + }); mongoose.connection.on('disconnected', () => { // reset state on disconnect if (process.env.NODE_ENV !== 'test') { // Do not interfere with testing console.info('Database disconnected'); - this.state.db = 0; + // this.state.db = 0; // prod database connects and disconnects automatically } }); process.on('SIGINT', () => { // close connection when app is terminated diff --git a/src/routes/sample.ts b/src/routes/sample.ts index f63b0b6..f3395c8 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -256,7 +256,7 @@ router.get('/samples', async (req, res, next) => { // projection.added = {$toDate: '$_id'}; // projection.added = { $convert: { input: '$_id', to: "date" } } // TODO: upgrade MongoDB version or find alternative } - if (!(filters.fields.indexOf('_id') >= 0)) { // disable _id explicitly + if (filters.fields.indexOf('_id') < 0 && filters.fields.indexOf('added') < 0) { // disable _id explicitly projection._id = false; } queryPtr.push({$project: projection}); @@ -266,8 +266,18 @@ router.get('/samples', async (req, res, next) => { if (err) return next(err); if (data[0].count) { res.header('x-total-items', data[0].count.length > 0 ? data[0].count[0].count : 0); + res.header('Access-Control-Expose-Headers', 'x-total-items'); data = data[0].samples; } + if (filters.fields.indexOf('added') >= 0) { // add added date + data.map(e => { + e.added = e._id.getTimestamp(); + if (filters.fields.indexOf('_id') < 0) { + delete e._id; + } + return e + }); + } if (filters['to-page'] < 0) { data.reverse(); } @@ -289,7 +299,15 @@ router.get('/samples', async (req, res, next) => { res.write('['); let count = 0; const stream = collection.aggregate(query).cursor().exec(); - stream.on('data', data => { res.write((count === 0 ? '' : ',\n') + JSON.stringify(data)); count ++; }); + stream.on('data', data => { + if (filters.fields.indexOf('added') >= 0) { // add added date + data.added = data._id.getTimestamp(); + if (filters.fields.indexOf('_id') < 0) { + delete data._id; + } + } + res.write((count === 0 ? '' : ',\n') + JSON.stringify(data)); count ++; + }); stream.on('close', () => { res.write(']'); res.end(); From 758eb0e143c6b14b9bb42cd9c4a5e06cfa62df4d Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 10 Jul 2020 13:09:15 +0200 Subject: [PATCH 82/83] implemented added filters --- src/db.ts | 2 +- src/routes/sample.ts | 80 ++++++++++++++++++++++++++++++++--- src/routes/validate/sample.ts | 6 +-- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/db.ts b/src/db.ts index 2b1f409..2bab005 100644 --- a/src/db.ts +++ b/src/db.ts @@ -7,7 +7,7 @@ import ChangelogModel from './models/changelog'; // database urls, prod db url is retrieved automatically const TESTING_URL = 'mongodb://localhost/dfopdb_test'; const DEV_URL = 'mongodb://localhost/dfopdb'; -const debugging = false; +const debugging = true; if (process.env.NODE_ENV !== 'production' && debugging) { mongoose.set('debug', true); // enable mongoose debug diff --git a/src/routes/sample.ts b/src/routes/sample.ts index f3395c8..ef87ab3 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -43,6 +43,54 @@ router.get('/samples', async (req, res, next) => { if (!filters['to-page']) { // set to-page default filters['to-page'] = 0; } + const addedFilter = filters.filters.find(e => e.field === 'added'); + if (addedFilter) { // convert added filter to object id + filters.filters.splice(filters.filters.findIndex(e => e.field === 'added'), 1); + if (addedFilter.mode === 'in') { + const v = []; // query value + addedFilter.values.forEach(value => { + const date = [new Date(value).setHours(0,0,0,0), new Date(value).setHours(23,59,59,999)]; + v.push({$and: [{ _id: { '$gte': dateToOId(date[0])}}, { _id: { '$lte': dateToOId(date[1])}}]}); + }); + filters.filters.push({mode: 'or', field: '_id', values: v}); + } + else if (addedFilter.mode === 'nin') { + addedFilter.values = addedFilter.values.sort(); + const v = []; // query value + + for (let i = 0; i <= addedFilter.values.length; i ++) { + v[i] = {$and: []}; + if (i > 0) { + const date = new Date(addedFilter.values[i - 1]).setHours(23,59,59,999); + v[i].$and.push({ _id: { '$gt': dateToOId(date)}}) ; + } + if (i < addedFilter.values.length) { + const date = new Date(addedFilter.values[i]).setHours(0,0,0,0); + v[i].$and.push({ _id: { '$lt': dateToOId(date)}}) ; + } + } + filters.filters.push({mode: 'or', field: '_id', values: v}); + } + else { + // start and end of day + const date = [new Date(addedFilter.values[0]).setHours(0,0,0,0), new Date(addedFilter.values[0]).setHours(23,59,59,999)]; + if (addedFilter.mode === 'lt') { // lt start + filters.filters.push({mode: 'lt', field: '_id', values: [dateToOId(date[0])]}); + } + if (addedFilter.mode === 'eq' || addedFilter.mode === 'lte') { // lte end + filters.filters.push({mode: 'lte', field: '_id', values: [dateToOId(date[1])]}); + } + if (addedFilter.mode === 'gt') { // gt end + filters.filters.push({mode: 'gt', field: '_id', values: [dateToOId(date[1])]}); + } + if (addedFilter.mode === 'eq' || addedFilter.mode === 'gte') { // gte start + filters.filters.push({mode: 'gte', field: '_id', values: [dateToOId(date[0])]}); + } + if (addedFilter.mode === 'ne') { + filters.filters.push({mode: 'or', field: '_id', values: [{ _id: { '$lt': dateToOId(date[0])}}, { _id: { '$gt': dateToOId(date[1])}}]}); + } + } + } const sortFilterKeys = filters.filters.map(e => e.field); @@ -80,7 +128,6 @@ router.get('/samples', async (req, res, next) => { {$addFields: {['sample.' + measurementName]: '$measurement.values'}}, // more restructuring {$replaceRoot: {newRoot: {$mergeObjects: [{$arrayElemAt: ['$sample', 0]}, {}]}}} ); - addFilterQueries(queryPtr, filters.filters.filter(e => sampleKeys.indexOf(e.field) >= 0)); // sample filters } else { // sorting with samples as starting collection collection = SampleModel; @@ -568,13 +615,23 @@ module.exports = router; async function numberGenerate (sample, req, res, next) { // generate number in format Location32, returns false on error const sampleData = await SampleModel - .findOne({number: new RegExp('^' + req.authDetails.location + '[0-9]+$', 'm')}) - .sort({number: -1}) - .lean() + // .findOne({number: new RegExp('^' + req.authDetails.location + '[0-9]+$', 'm')}) + // .sort({number: -1}) + // .lean() + .aggregate([ + {$match: {number: new RegExp('^' + 'Rng' + '[0-9]+$', 'm')}}, + // {$addFields: {number2: {$toDecimal: {$arrayElemAt: [{$split: [{$arrayElemAt: [{$split: ['$number', 'Rng']}, 1]}, '_']}, 0]}}}}, // not working with MongoDb 3.6 + {$addFields: {sortNumber: {$let: { + vars: {tmp: {$concat: ['000000000000000000000000000000', {$arrayElemAt: [{$split: [{$arrayElemAt: [{$split: ['$number', 'Rng']}, 1]}, '_']}, 0]}]}}, + in: {$substrCP: ['$$tmp', {$subtract: [{$strLenCP: '$$tmp'}, 30]}, {$strLenCP: '$$tmp'}]} + }}}}, + {$sort: {sortNumber: -1}}, + {$limit: 1} + ]) .exec() .catch(err => next(err)); if (sampleData instanceof Error) return false; - return req.authDetails.location + (sampleData ? Number(sampleData.number.replace(/[^0-9]+/g, '')) + 1 : 1); + return req.authDetails.location + (sampleData[0] ? Number(sampleData[0].number.replace(/[^0-9]+/g, '')) + 1 : 1); } async function numberCheck(sample, res, next) { @@ -707,5 +764,16 @@ function addFilterQueries (queryPtr, filters) { // returns array of match queri } function filterQueries (filters) { - return filters.map(e => ({[e.field]: {['$' + e.mode]: (e.mode.indexOf('in') >= 0 ? e.values : e.values[0])}})) // add filter criteria as {field: {$mode: value}}, only use first value when mode is not in/nin + return filters.map(e => { + if (e.mode === 'or') { // allow or queries (needed for $ne added) + return {['$' + e.mode]: e.values}; + } + else { + return {[e.field]: {['$' + e.mode]: (e.mode.indexOf('in') >= 0 ? e.values : e.values[0])}}; // add filter criteria as {field: {$mode: value}}, only use first value when mode is not in/nin + } + }); +} + +function dateToOId (date) { // convert date to ObjectId + return mongoose.Types.ObjectId(Math.floor(date / 1000).toString(16) + '0000000000000000'); } \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index f84a5be..ef0fa0a 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -175,7 +175,7 @@ export default class SampleValidate { let validator; let field = data.filters[i].field if (/material\./.test(field)) { // select right validation model - validator = MaterialValidate.outputV(); + validator = MaterialValidate.outputV().append({number: Joi.string().max(128).allow('')}); field = field.replace('material.', ''); } else if (/measurements\./.test(field)) { @@ -195,7 +195,7 @@ export default class SampleValidate { validator = Joi.object(this.sample); } const {value, error} = validator.validate({[field]: e}); - if (error) throw error; // reject invalid values + if (error) throw error; // reject invalid values // TODO: return exact error description, handle in frontend filters return value[field]; }); } @@ -215,7 +215,7 @@ export default class SampleValidate { filters: Joi.array().items(Joi.object({ mode: Joi.string().valid('eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'nin'), field: Joi.string().pattern(new RegExp('^(' + this.fieldKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')$', 'm')), - values: Joi.array().items(Joi.alternatives().try(Joi.string().max(128), Joi.number(), Joi.boolean())).min(1) + values: Joi.array().items(Joi.alternatives().try(Joi.string().max(128), Joi.number(), Joi.boolean(), Joi.date().iso())).min(1) })).default([]) }).with('to-page', 'page-size').validate(data); } From 3dda3d77a13f84a40f8132f49f4ad704f2810a75 Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Tue, 14 Jul 2020 12:07:43 +0200 Subject: [PATCH 83/83] minor fixes --- data_import/import.js | 14 ++++++++++++-- src/routes/root.ts | 1 + src/routes/sample.ts | 3 ++- src/routes/validate/sample.ts | 3 ++- src/routes/validate/template.ts | 1 + 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/data_import/import.js b/data_import/import.js index 627e1b8..dc8c8d8 100644 --- a/data_import/import.js +++ b/data_import/import.js @@ -11,8 +11,8 @@ const kfDoc = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\kf.csv'; const vzDoc = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\vz.csv'; const nmDocs = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\nmDocs'; // NormMaster Documents const dptFiles = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\DPT'; // Spectrum files -// const host = 'http://localhost:3000'; -const host = 'https://definma-api.apps.de1.bosch-iot-cloud.com'; +const host = 'http://localhost:3000'; +// const host = 'https://definma-api.apps.de1.bosch-iot-cloud.com'; let data = []; // metadata contents let materials = {}; let samples = []; @@ -127,6 +127,16 @@ async function allDpts() { measurement_template }; data.values.dpt = f.split('\r\n').map(e => e.split(',')); + let rescale = false; + for (let i in data.values.dpt) { + if (data.values.dpt[i][1] > 2) { + rescale = true; + break; + } + } + if (rescale) { + data.values.dpt = data.values.dpt.map(e => [e[0], e[1] / 100]); + } await axios({ method: 'post', url: host + '/measurement/new', diff --git a/src/routes/root.ts b/src/routes/root.ts index 946948f..1547844 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -17,6 +17,7 @@ router.get('/authorized', (req, res) => { res.json({status: 'Authorization successful', method: req.authDetails.method}); }); +// TODO: evaluate exact changelog functionality (restoring, delting after time, etc.) router.get('/changelog/:timestamp/:page?/:pagesize?', (req, res, next) => { if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; diff --git a/src/routes/sample.ts b/src/routes/sample.ts index ef87ab3..91ada86 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -118,7 +118,7 @@ router.get('/samples', async (req, res, next) => { } queryPtr[0].$match.$and.push({measurement_template: mongoose.Types.ObjectId(measurementTemplate._id)}); // find measurements to sort if (filters.filters.find(e => e.field === filters.sort[0])) { // sorted measurement should also be filtered - queryPtr[0].$match.$and.push(...filterQueries(filters.filters.find(e => e.field === filters.sort[0]))); + queryPtr[0].$match.$and.push(...filterQueries(filters.filters.filter(e => e.field === filters.sort[0]).map(e => {e.field = 'values.' + e.field.split('.')[2]; return e; }))); } queryPtr.push( ...sortQuery(filters, ['values.' + measurementParam, 'sample_id'], sortStartValue), // sort measurements @@ -764,6 +764,7 @@ function addFilterQueries (queryPtr, filters) { // returns array of match queri } function filterQueries (filters) { + console.log(filters); return filters.map(e => { if (e.mode === 'or') { // allow or queries (needed for $ne added) return {['$' + e.mode]: e.values}; diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index ef0fa0a..3fb28d9 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -182,8 +182,8 @@ export default class SampleValidate { validator = Joi.object({ value: Joi.alternatives() .try( - Joi.string().max(128), Joi.number(), + Joi.string().max(128), Joi.boolean(), Joi.array() ) @@ -195,6 +195,7 @@ export default class SampleValidate { validator = Joi.object(this.sample); } const {value, error} = validator.validate({[field]: e}); + console.log(value); if (error) throw error; // reject invalid values // TODO: return exact error description, handle in frontend filters return value[field]; }); diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts index 7a63d1d..ae9426a 100644 --- a/src/routes/validate/template.ts +++ b/src/routes/validate/template.ts @@ -1,6 +1,7 @@ import Joi from '@hapi/joi'; import IdValidate from './id'; +// TODO: do not allow a . in the name export default class TemplateValidate { private static template = { name: Joi.string()