From 14ba1655bace362c19df25990693a4a665826dcf Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 28 May 2020 17:05:23 +0200 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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 08/11] 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 09/11] 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 10/11] 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 11/11] 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;