From 63a5f5ebd1eb8204602fd45787dbc7bd31cc852e Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Thu, 7 May 2020 21:55:29 +0200 Subject: [PATCH 1/2] implemented more /sample methods --- .idea/dataSources.xml | 11 + .idea/dbnavigator.xml | 458 ++++++++++++++++++++++++++++++ api/sample.yaml | 10 +- src/db.ts | 26 +- src/helpers/mail.ts | 2 +- src/index.ts | 4 +- src/routes/material.spec.ts | 128 ++++++++- src/routes/material.ts | 11 +- src/routes/sample.spec.ts | 393 ++++++++++++++++++++++++- src/routes/sample.ts | 208 ++++++++++---- src/routes/template.spec.ts | 98 ++++++- src/routes/template.ts | 6 +- src/routes/user.spec.ts | 16 +- src/routes/user.ts | 11 +- src/routes/validate/id.ts | 4 +- src/routes/validate/material.ts | 6 +- src/routes/validate/note_field.ts | 8 +- src/routes/validate/res400.ts | 3 + src/routes/validate/sample.ts | 45 +-- src/routes/validate/user.ts | 22 +- src/test/db.json | 13 +- 21 files changed, 1322 insertions(+), 161 deletions(-) create mode 100644 .idea/dataSources.xml create mode 100644 .idea/dbnavigator.xml create mode 100644 src/routes/validate/res400.ts diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..54163ef --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,11 @@ + + + + + mongo + true + com.dbschema.MongoJdbcDriver + mongodb://localhost:27017 + + + \ No newline at end of file diff --git a/.idea/dbnavigator.xml b/.idea/dbnavigator.xml new file mode 100644 index 0000000..ad4eaf6 --- /dev/null +++ b/.idea/dbnavigator.xml @@ -0,0 +1,458 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/api/sample.yaml b/api/sample.yaml index 32bb6ed..8ba92af 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -41,8 +41,8 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: TODO change sample - description: 'Auth: basic, levels: write, maintain, dev, admin' + summary: change sample + description: 'Auth: basic, levels: write, maintain, dev, admin, only maintain and admin are allowed to edit samples created by another user' tags: - /sample security: @@ -59,7 +59,7 @@ content: application/json: schema: - $ref: 'api.yaml#/components/schemas/SampleDetail' + $ref: 'api.yaml#/components/schemas/SampleRefs' 400: $ref: 'api.yaml#/components/responses/400' 401: @@ -71,8 +71,8 @@ 500: $ref: 'api.yaml#/components/responses/500' delete: - summary: TODO delete sample - description: 'Auth: basic, levels: write, maintain, dev, admin' + summary: delete sample + description: 'Auth: basic, levels: write, maintain, dev, admin, only maintain and admin are allowed to edit samples created by another user' tags: - /sample security: diff --git a/src/db.ts b/src/db.ts index f188468..89c3183 100644 --- a/src/db.ts +++ b/src/db.ts @@ -42,19 +42,19 @@ export default class db { }); mongoose.connection.on('error', console.error.bind(console, 'connection error:')); mongoose.connection.on('disconnected', () => { // reset state on disconnect - console.log('Database disconnected'); + console.info('Database disconnected'); this.state.db = 0; done(); }); process.on('SIGINT', () => { // close connection when app is terminated mongoose.connection.close(() => { - console.log('Mongoose default connection disconnected through app termination'); + console.info('Mongoose default connection disconnected through app termination'); process.exit(0); }); }); mongoose.connection.once('open', () => { mongoose.set('useFindAndModify', false); - console.log(process.env.NODE_ENV === 'test' ? '' : `Connected to ${connectionString}`); + console.info(process.env.NODE_ENV === 'test' ? '' : `Connected to ${connectionString}`); this.state.db = mongoose.connection; done(); }); @@ -90,13 +90,7 @@ export default class db { let loadCounter = 0; // count number of loaded collections to know when to return done() Object.keys(json.collections).forEach(collectionName => { // create each collection - for(let i in json.collections[collectionName]) { // convert $oid fields to actual ObjectIds - Object.keys(json.collections[collectionName][i]).forEach(key => { - if (json.collections[collectionName][i][key] !== null && json.collections[collectionName][i][key].hasOwnProperty('$oid')) { - json.collections[collectionName][i][key] = mongoose.Types.ObjectId(json.collections[collectionName][i][key].$oid); - } - }) - } + json.collections[collectionName] = this.oidResolve(json.collections[collectionName]); this.state.db.createCollection(collectionName, (err, collection) => { collection.insertMany(json.collections[collectionName], () => { // insert JSON data if (++ loadCounter >= Object.keys(json.collections).length) { // all collections loaded @@ -106,4 +100,16 @@ export default class db { }); }); } + + private static oidResolve (object: any) { // resolve $oid fields to actual ObjectIds recursively + Object.keys(object).forEach(key => { + if (object[key] !== null && object[key].hasOwnProperty('$oid')) { + object[key] = mongoose.Types.ObjectId(object[key].$oid); + } + else if (typeof object[key] === 'object' && object[key] !== null) { + object[key] = this.oidResolve(object[key]); + } + }); + return object; + } }; \ No newline at end of file diff --git a/src/helpers/mail.ts b/src/helpers/mail.ts index 949d243..792f35f 100644 --- a/src/helpers/mail.ts +++ b/src/helpers/mail.ts @@ -30,7 +30,7 @@ export default (mailAddress, subject, content, f) => { // callback, executed em }); } else if (process.env.NODE_ENV === 'test') { - console.log('Sending mail to ' + mailAddress + ': -- ' + subject + ' -- ' + content); + console.info('Sending mail to ' + mailAddress + ': -- ' + subject + ' -- ' + content); f(); } else { // dev diff --git a/src/index.ts b/src/index.ts index 63ca19e..3a87996 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import db from './db'; // tell if server is running in debug or production environment -console.log(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); +console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); // mongodb connection @@ -75,7 +75,7 @@ app.use((err, req, res, ignore) => { // internal server error handling // hook up server to port const server = app.listen(port, () => { - console.log(process.env.NODE_ENV === 'test' ? '' : `Listening on http://localhost:${port}`); + console.info(process.env.NODE_ENV === 'test' ? '' : `Listening on http://localhost:${port}`); }); module.exports = server; \ No newline at end of file diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 7b84c08..aa9a484 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -171,14 +171,54 @@ describe('/material', () => { res: {status: 'Material name already taken'} }); }); - it('rejects wrong material properties', done => { + it('rejects a wrong mineral property', done => { TestHelper.request(server, done, { method: 'put', url: '/material/100000000000000000000001', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {mineral: 'x', glass_fiber: 'x', carbon_fiber: 'x', numbers: [{colorxx: 'black', number: 'xxx'}]}, - res: {status: 'Invalid body format'} + req: {mineral: 'x'}, + res: {status: 'Invalid body format', details: '"mineral" must be a number'} + }); + }); + it('rejects a wrong glass_fiber property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {glass_fiber: 'x'}, + res: {status: 'Invalid body format', details: '"glass_fiber" must be a number'} + }); + }); + it('rejects a wrong carbon_fiber property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {carbon_fiber: 'x'}, + res: {status: 'Invalid body format', details: '"carbon_fiber" must be a number'} + }); + }); + it('rejects a wrong color name property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {numbers: [{colorxx: 'black', number: 55}]}, + res: {status: 'Invalid body format', details: '"numbers[0].color" is required'} + }); + }); + it('rejects a wrong color number property', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {numbers: [{color: 'black', number: 'xxx'}]}, + res: {status: 'Invalid body format', details: '"numbers[0].number" must be a number'} }); }); it('rejects an invalid id', done => { @@ -347,24 +387,94 @@ describe('/material', () => { res: {status: 'Material name already taken'} }); }); - it('rejects wrong material properties', done => { + it('rejects a missing name', done => { TestHelper.request(server, done, { method: 'post', url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 'x', glass_fiber: 'x', carbon_fiber: 'x', numbers: [{colorxx: 'black', number: 'xxx'}]}, - res: {status: 'Invalid body format'} + req: {supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"name" is required'} }); }); - it('rejects incomplete material properties', done => { + it('rejects a missing supplier', done => { TestHelper.request(server, done, { method: 'post', url: '/material/new', auth: {basic: 'janedoe'}, httpStatus: 400, - req: {name: 'Crastin CE 2510'}, - res: {status: 'Invalid body format'} + req: {name: 'Crastin CE 2510', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"supplier" is required'} + }); + }); + it('rejects a missing group', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"group" is required'} + }); + }); + it('rejects a missing mineral property', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"mineral" is required'} + }); + }); + it('rejects a missing glass_fiber property', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"glass_fiber" is required'} + }); + }); + it('rejects a missing carbon_fiber property', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, numbers: [{color: 'black', number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"carbon_fiber" is required'} + }); + }); + it('rejects a missing numbers array', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0}, + res: {status: 'Invalid body format', details: '"numbers" is required'} + }); + }); + it('rejects a missing color name', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{number: 5515798402}]}, + res: {status: 'Invalid body format', details: '"numbers[0].color" is required'} + }); + }); + it('rejects a missing color number', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black'}]}, + res: {status: 'Invalid body format', details: '"numbers[0].number" is required'} }); }); it('rejects an API key', done => { diff --git a/src/routes/material.ts b/src/routes/material.ts index 5628fa6..29362e2 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -3,6 +3,7 @@ import express from 'express'; import MaterialValidate from './validate/material'; import MaterialModel from '../models/material' import IdValidate from './validate/id'; +import res400 from './validate/res400'; const router = express.Router(); @@ -34,10 +35,7 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; const {error, value: material} = MaterialValidate.input(req.body, 'change'); - if (error) { - res.status(400).json({status: 'Invalid body format'}); - return; - } + if (error) return res400(error, res); if (material.hasOwnProperty('name')) { MaterialModel.find({name: material.name}).lean().exec((err, data) => { @@ -87,10 +85,7 @@ router.post('/material/new', (req, res, next) => { // validate input const {error, value: material} = MaterialValidate.input(req.body, 'new'); - if (error) { - res.status(400).json({status: 'Invalid body format'}); - return; - } + if (error) return res400(error, res); MaterialModel.find({name: material.name}).lean().exec((err, data) => { if (err) return next(err); diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 857556c..98017f0 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -69,6 +69,387 @@ describe('/sample', () => { }); }); + describe('PUT /sample/{id}', () => { + it('returns the right sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {}, + res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} + }); + }); + it('keeps unchanged properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', notes: {}}, + res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} + }); + }); + it('changes the given properties', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + }).end(err => { + if (err) return done (err); + SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'validated', 'material_id', 'note_id', 'user_id', '__v'); + should(data).have.property('_id'); + should(data).have.property('number', '10'); + should(data).have.property('color', 'signalviolet'); + should(data).have.property('type', 'part'); + should(data).have.property('batch', '114531'); + should(data).have.property('validated').be.type('boolean'); + should(data.material_id.toString()).be.eql('100000000000000000000002'); + should(data.user_id.toString()).be.eql('000000000000000000000002'); + should(data).have.property('note_id'); + NoteModel.findById(data.note_id).lean().exec((err, data: any) => { + if (err) return done (err); + should(data).have.property('_id'); + should(data).have.property('comment', 'Testcomment'); + should(data).have.property('sample_references'); + should(data.sample_references).have.lengthOf(1); + should(data.sample_references[0].id.toString()).be.eql('400000000000000000000003'); + should(data.sample_references[0]).have.property('relation', 'part to this sample'); + done(); + }); + }) + }); + }); + it('adjusts the note_fields correctly', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {notes: {comment: 'Testcomment', sample_references: [], custom_fields: {field1: 'value 1'}}} + }).end(err => { + if (err) return done(err); + NoteFieldModel.findOne({name: 'not allowed for new applications'}).lean().exec((err, data) => { + console.log(data); + if (err) return done(err); + should(data).have.property('qty', 1); + NoteFieldModel.findOne({name: 'field1'}).lean().exec((err, data) => { + if (err) return done(err); + console.log(data); + should(data).have.property('qty', 1); + done(); + }); + }); + }); + }); + it('deletes old note_fields', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {notes: {comment: 'Testcomment', sample_references: []}} + }).end(err => { + if (err) return done (err); + NoteFieldModel.findOne({name: 'another_field'}).lean().exec((err, data) => { + if (err) return done (err); + should(data).be.null(); + done(); + }); + }); + }); + it('keeps untouched notes', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {number: '111'} + }).end((err, res) => { + if (err) return done (err); + NoteModel.findById(res.body.note_id).lean().exec((err, data) => { + if (err) return done (err); + console.log(data); + should(data).not.be.null(); + should(data).have.property('comment', 'Stoff gesperrt'); + should(data).have.property('sample_references').have.lengthOf(0); + done(); + }); + }); + }); + it('deletes old notes', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {notes: {comment: 'Testcomment', sample_references: []}} + }).end(err => { + if (err) return done (err); + NoteModel.findById('500000000000000000000003').lean().exec((err, data) => { + if (err) return done (err); + should(data).be.null(); + done(); + }); + }); + }); + it('rejects a color not defined for the material', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Color not available for material'} + }); + }); + it('rejects an unknown material id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '000000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Material not available'} + }); + }); + it('rejects a sample number in use', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: '21', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Sample number already taken'} + }); + }); + it('rejects an invalid sample reference', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '000000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Sample reference not available'} + }); + }); + it('rejects an invalid material id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/10000000000h000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }); + }); + it('rejects changes for samples from another user for a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000003', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('accepts changes for samples from another user for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'user'}, + httpStatus: 403, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/000000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}} + }); + }) + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + httpStatus: 401, + req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, + }); + }); + }); + + describe('DELETE /sample/{id}', () => { + it('deletes the sample', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + SampleModel.findById('400000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).be.null(); + done(); + }); + }); + }); + it('deletes the notes of the sample', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + NoteModel.findById('500000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).be.null(); + done(); + }); + }); + }); + it('adjusts the note_fields correctly', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + NoteFieldModel.findOne({name: 'not allowed for new applications'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.property('qty', 1); + NoteFieldModel.findOne({name: 'another_field'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).be.null(); + done(); + }); + }); + }); + }); + it('resets references to this sample', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + setTimeout(() => { // background action takes some time before we can check + NoteModel.findById('500000000000000000000003').lean().exec((err, data) => { + if (err) return done(err); + console.log(data); + should(data).have.property('sample_references').with.lengthOf(0); + done(); + }); + }, 100); + + }); + }); + it('lets admin/maintain users delete samples of other users', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + SampleModel.findById('400000000000000000000001').lean().exec((err, data) => { + if (err) return done(err); + should(data).be.null(); + done(); + }); + }); + }); + it('rejects deleting samples of other users for write users', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000h00000000004', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000004', + auth: {basic: 'user'}, + httpStatus: 403 + }); + }); + it('returns 404 for an unknown id', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/000000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + httpStatus: 401 + }); + }); + }); + describe('POST /sample/new', () => { it('returns the right sample', done => { TestHelper.request(server, done, { @@ -209,7 +590,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 400, req: {number: 'Rng172', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"color" is required'} }); }); it('rejects a missing sample number', done => { @@ -219,7 +600,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 400, req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"number" is required'} }); }); it('rejects a missing type', done => { @@ -229,7 +610,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 400, req: {number: 'Rng172', color: 'black', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"type" is required'} }); }); it('rejects a missing batch', done => { @@ -239,7 +620,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 400, req: {number: 'Rng172', color: 'black', type: 'granulate', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"batch" is required'} }); }); it('rejects a missing material id', done => { @@ -249,7 +630,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 400, req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"material_id" is required'} }); }); it('rejects an invalid material id', done => { @@ -259,7 +640,7 @@ describe('/sample', () => { auth: {basic: 'janedoe'}, httpStatus: 400, req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'} }); }); it('rejects an API key', done => { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index bbebaba..7415912 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -2,10 +2,12 @@ import express from 'express'; import SampleValidate from './validate/sample'; import NoteFieldValidate from './validate/note_field'; +import res400 from './validate/res400'; import SampleModel from '../models/sample' import MaterialModel from '../models/material'; import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; +import IdValidate from './validate/id'; @@ -20,66 +22,118 @@ router.get('/samples', (req, res, next) => { }) }); - -router.post('/sample/new', (req, res, next) => { +router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; - const {error, value: sample} = SampleValidate.input(req.body, 'new'); - if (error) { - return res.status(400).json({status: 'Invalid body format'}); - } + const {error, value: sample} = SampleValidate.input(req.body, 'change'); + if (error) return res400(error, res); - MaterialModel.findById(sample.material_id).lean().exec((err, data: any) => { // validate material_id + SampleModel.findById(req.params.id).lean().exec(async (err, sampleData: any) => { // check if id exists if (err) return next(err); - if (!data) { // could not find material_id - return res.status(400).json({status: 'Material not available'}); + if (!sampleData) { + return res.status(404).json({status: 'Not found'}); } - if (!data.numbers.find(e => e.color === sample.color)) { // color for material not specified - return res.status(400).json({status: 'Color not available for material'}); - } - SampleModel.findOne({number: sample.number}).lean().exec((err, data) => { // validate sample number - if (err) return next(err); - if (data) { // found entry with sample number - return res.status(400).json({status: 'Sample number already taken'}); - } + // only maintain and admin are allowed to edit other user's data + if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; - if (sample.notes.sample_references.length > 0) { // validate sample_references - let referencesCount = sample.notes.sample_references.length; - sample.notes.sample_references.forEach(reference => { - SampleModel.findById(reference.id).lean().exec((err, data) => { - if (err) return next(err); - if (!data) { - return res.status(400).json({status: 'Sample reference not available'}); - } - referencesCount --; - if (referencesCount <= 0) { - f(); - } + if (sample.hasOwnProperty('number') && sample.number !== sampleData.number) { + if (!await numberCheck(sample, res, next)) return; + } + if (sample.hasOwnProperty('material_id')) { + if (!await materialCheck(sample, res, next)) return; + } + else if (sample.hasOwnProperty('color')) { + if (!await materialCheck(sample, res, next, sampleData.material_id)) return; + } + + if (sample.hasOwnProperty('notes') && sampleData.note_id !== null) { // deal with old notes data + NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { + if (err) return console.error(err); + if (data.hasOwnProperty('custom_fields')) { // update note_fields + customFieldsChange(Object.keys(data.custom_fields), -1); + } + NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec(err => { // delete old notes + if (err) return console.error(err); + }) + }); + } + if (sample.hasOwnProperty('notes') && Object.keys(sample.notes).length > 0) { // save new notes + if (!await sampleRefCheck(sample, res, next)) return; + if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields + customFieldsChange(Object.keys(sample.notes.custom_fields), 1); + } + let data = await new NoteModel(sample.notes).save().catch(err => { return next(err)}); // save new notes + delete sample.notes; + sample.note_id = data._id; + } + SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).lean().exec((err, data) => { + if (err) return next(err); + res.json(SampleValidate.output(data)); + }); + + }); +}); + +router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + SampleModel.findById(req.params.id).lean().exec(async (err, sampleData: any) => { // check if id exists + if (err) return next(err); + if (!sampleData) { + return res.status(404).json({status: 'Not found'}); + } + // only maintain and admin are allowed to edit other user's data + if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; + + SampleModel.findByIdAndDelete(req.params.id).lean().exec(err => { // delete sample + if (err) return next(err); + if (sampleData.note_id !== null) { + NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec((err, data: any) => { // delete notes + if (err) return next(err); + console.log(data); + if (data.hasOwnProperty('custom_fields')) { // update note_fields + customFieldsChange(Object.keys(data.custom_fields), -1); + } + res.json({status: 'OK'}); + NoteModel.updateMany({'sample_references.id': req.params.id}, {$unset: {'sample_references.$': null}}).lean().exec(err => { // remove sample_references + if (err) console.error(err); + NoteModel.collection.updateMany({sample_references: null}, {$pull: {sample_references: null}}, err => { // only works with native MongoDB driver somehow + if (err) console.error(err); + }); }); }); } else { - f(); - } - - if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { - customFieldsAdd(Object.keys(sample.notes.custom_fields)); - } - - function f() { // to resolve async - new NoteModel(sample.notes).save((err, data) => { - if (err) return next(err); - delete sample.notes; - sample.note_id = data._id; - sample.user_id = req.authDetails.id; - new SampleModel(sample).save((err, data) => { - if (err) return next(err); - res.json(SampleValidate.output(data.toObject())); - }); - }); + res.json({status: 'OK'}); } }); - }) + }); +}); + +router.post('/sample/new', async (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const {error, value: sample} = SampleValidate.input(req.body, 'new'); + if (error) return res400(error, res); + + if (!await numberCheck(sample, res, next)) return; + if (!await materialCheck(sample, res, next)) return; + if (!await sampleRefCheck(sample, res, next)) return; + + if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields + customFieldsChange(Object.keys(sample.notes.custom_fields), 1); + } + + new NoteModel(sample.notes).save((err, data) => { + if (err) return next(err); + delete sample.notes; + sample.note_id = data._id; + sample.user_id = req.authDetails.id; + new SampleModel(sample).save((err, data) => { + if (err) return next(err); + res.json(SampleValidate.output(data.toObject())); + }); + }); }); router.get('/sample/notes/fields', (req, res, next) => { @@ -95,15 +149,69 @@ router.get('/sample/notes/fields', (req, res, next) => { module.exports = router; -function customFieldsAdd (fields) { +async function numberCheck (sample, res, next) { // validate number, returns false if invalid + const sampleData = await SampleModel.findOne({number: sample.number}).lean().exec().catch(err => { return next(err)}); + if (sampleData) { // found entry with sample number + res.status(400).json({status: 'Sample number already taken'}); + return false + } + return true; +} + +async function materialCheck (sample, res, next, id = sample.material_id) { // validate material_id and color, returns false if invalid + const materialData = await MaterialModel.findById(id).lean().exec().catch(err => {next(err);}) as any; + if (materialData instanceof Error) { + return false; + } + if (!materialData) { // could not find material_id + res.status(400).json({status: 'Material not available'}); + return false; + } + if (sample.hasOwnProperty('color') && !materialData.numbers.find(e => e.color === sample.color)) { // color for material not specified + res.status(400).json({status: 'Color not available for material'}); + return false; + } + return true; +} + +function sampleRefCheck (sample, res, next) { // validate sample_references, resolves false for invalid reference + return new Promise(resolve => { + if (sample.notes.sample_references.length > 0) { // there are sample_references + let referencesCount = sample.notes.sample_references.length; + sample.notes.sample_references.forEach(reference => { + SampleModel.findById(reference.id).lean().exec((err, data) => { + if (err) {next(err); resolve(false)} + if (!data) { + res.status(400).json({status: 'Sample reference not available'}); + return resolve(false); + } + referencesCount --; + if (referencesCount <= 0) { + resolve(true); + } + }); + }); + } + else { + resolve(true); + } + }); +} + +function customFieldsChange (fields, amount) { fields.forEach(field => { - NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: 1}}).lean().exec((err, data) => { // check if field exists + NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: amount}}, {new: true}).lean().exec((err, data: any) => { // check if field exists if (err) return console.error(err); if (!data) { // new field new NoteFieldModel({name: field, qty: 1}).save(err => { if (err) return console.error(err); }) } + else if (data.qty <= 0) { + NoteFieldModel.findOneAndDelete({name: field}).lean().exec(err => { + if (err) return console.error(err); + }); + } }); }); } \ No newline at end of file diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index 68b3d4a..6a4c7af 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -182,14 +182,54 @@ describe('/template', () => { }); }); }); - it('rejects an incomplete template for a new name', done => { + it('rejects a missing name for a new name', done => { TestHelper.request(server, done, { method: 'put', url: '/template/treatment/heat%20aging', auth: {basic: 'admin'}, httpStatus: 400, - req: {parameters: [{name: 'time'}]}, - res: {status: 'Invalid body format'} + req: {parameters: [{name: 'time', range: {min: 1}}]}, + res: {status: 'Invalid body format', details: '"name" is required'} + }); + }); + it('rejects missing parameters for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging'}, + res: {status: 'Invalid body format', details: '"parameters" is required'} + }); + }); + it('rejects a missing parameter name for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{range: {min: 1}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].name" is required'} + }); + }); + it('rejects a missing parameter range for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{name: 'time'}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} + }); + }); + it('rejects a an invalid parameter range property for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/treatment/heat%20aging', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'heat aging', parameters: [{name: 'time', range: {xx: 1}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} }); }); it('rejects already existing names', done => { @@ -209,7 +249,7 @@ describe('/template', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {parameters: [{name: 'time'}], xx: 33}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"name" is required'} }); }); it('rejects an API key', done => { @@ -466,14 +506,54 @@ describe('/template', () => { }); }); }); - it('rejects an incomplete template for a new name', done => { + it('rejects a missing name for a new name', done => { TestHelper.request(server, done, { method: 'put', - url: '/template/measurement/vz', + url: '/template/measurement/spectrum2', auth: {basic: 'admin'}, httpStatus: 400, - req: {parameters: [{name: 'vz'}]}, - res: {status: 'Invalid body format'} + req: {parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, + res: {status: 'Invalid body format', details: '"name" is required'} + }); + }); + it('rejects missing parameters for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum2', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum'}, + res: {status: 'Invalid body format', details: '"parameters" is required'} + }); + }); + it('rejects a missing parameter name for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum2', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{range: {min: 0, max: 1000}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].name" is required'} + }); + }); + it('rejects a missing parameter range for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum2', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{name: 'data point table'}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} + }); + }); + it('rejects a an invalid parameter range property for a new name', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/spectrum2', + auth: {basic: 'admin'}, + httpStatus: 400, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {xx: 0}}]}, + res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'} }); }); it('rejects already existing names', done => { @@ -493,7 +573,7 @@ describe('/template', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {parameters: [{name: 'dpt'}], xx: 33}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"parameters[0].range" is required'} }); }); it('rejects an API key', done => { diff --git a/src/routes/template.ts b/src/routes/template.ts index 1e859cd..afd686e 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -3,6 +3,7 @@ import express from 'express'; import TemplateValidate from './validate/template'; import TemplateTreatmentModel from '../models/treatment_template'; import TemplateMeasurementModel from '../models/measurement_template'; +import res400 from './validate/res400'; const router = express.Router(); @@ -41,10 +42,7 @@ router.put('/template/:collection(measurement|treatment)/:name', (req, res, next if (err) next (err); const templateState = data? 'change': 'new'; const {error, value: template} = TemplateValidate.input(req.body, templateState); - if (error) { - res.status(400).json({status: 'Invalid body format'}); - return; - } + if (error) return res400(error, res); if (template.hasOwnProperty('name') && template.name !== req.params.name) { collectionModel.find({name: template.name}).lean().exec((err, data) => { diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index b103ef7..a3a0ed9 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -224,7 +224,7 @@ describe('/user', () => { req: {level: 'read'} }).end((err, res) => { if (err) return done (err); - should(res.body).be.eql({status: 'Invalid body format'}); + should(res.body).be.eql({status: 'Invalid body format', details: '"level" is not allowed'}); UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => { if (err) return done(err); should(data).have.lengthOf(1); @@ -267,7 +267,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', location: 44, device_name: 'Alpha II'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"location" must be a string'} }); }); it('rejects an invalid email address', done => { @@ -277,7 +277,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {email: 'john.doe'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"email" must be a valid email'} }); }); it('rejects an invalid password', done => { @@ -287,7 +287,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {pass: 'password'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$/'} }); }); it('rejects requests from non-admins for another user', done => { @@ -515,7 +515,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 44, device_name: 'Alpha II'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"location" must be a string'} }); }); it('rejects an invalid user level', done => { @@ -525,7 +525,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'xxx', location: 'Rng', device_name: 'Alpha II'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"level" must be one of [read, write, maintain, dev, admin]'} }); }); it('rejects an invalid email address', done => { @@ -535,7 +535,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {email: 'john.doe', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"email" must be a valid email'} }); }); it('rejects an invalid password', done => { @@ -545,7 +545,7 @@ describe('/user', () => { auth: {basic: 'admin'}, httpStatus: 400, req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'password', level: 'read', location: 'Rng', device_name: 'Alpha II'}, - res: {status: 'Invalid body format'} + res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$/'} }); }); it('rejects requests from non-admins', done => { diff --git a/src/routes/user.ts b/src/routes/user.ts index a0161f9..db78527 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -5,6 +5,7 @@ import bcrypt from 'bcryptjs'; import UserValidate from './validate/user'; import UserModel from '../models/user'; import mail from '../helpers/mail'; +import res400 from './validate/res400'; const router = express.Router(); @@ -46,10 +47,7 @@ router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi username = req.params.username; } const {error, value: user} = UserValidate.input(req.body, 'change' + (req.authDetails.level === 'admin'? 'admin' : '')); - if (error) { - res.status(400).json({status: 'Invalid body format'}); - return; - } + if (error) return res400(error, res); if (user.hasOwnProperty('pass')) { user.pass = bcrypt.hashSync(user.pass, 10); @@ -122,10 +120,7 @@ router.post('/user/new', (req, res, next) => { // validate input const {error, value: user} = UserValidate.input(req.body, 'new'); - if (error) { - res.status(400).json({status: 'Invalid body format'}); - return; - } + if (error) return res400(error, res); // check that user does not already exist UserModel.find({name: user.name}).lean().exec( (err, data:any) => { diff --git a/src/routes/validate/id.ts b/src/routes/validate/id.ts index 5409993..a9bb70a 100644 --- a/src/routes/validate/id.ts +++ b/src/routes/validate/id.ts @@ -1,7 +1,7 @@ -import joi from '@hapi/joi'; +import Joi from '@hapi/joi'; export default class IdValidate { - private static id = joi.string().pattern(new RegExp('[0-9a-f]{24}')).length(24); + private static id = Joi.string().pattern(new RegExp('[0-9a-f]{24}')).length(24); static get () { return this.id; diff --git a/src/routes/validate/material.ts b/src/routes/validate/material.ts index 54cd749..c8b6e91 100644 --- a/src/routes/validate/material.ts +++ b/src/routes/validate/material.ts @@ -31,9 +31,11 @@ export default class MaterialValidate { // validate input for material numbers: joi.array() .items(joi.object({ color: joi.string() - .max(128), + .max(128) + .required(), number: joi.number() .min(0) + .required() })) }; @@ -46,7 +48,7 @@ export default class MaterialValidate { // validate input for material mineral: this.material.mineral.required(), glass_fiber: this.material.glass_fiber.required(), carbon_fiber: this.material.carbon_fiber.required(), - numbers: this.material.numbers + numbers: this.material.numbers.required() }).validate(data); } else if (param === 'change') { diff --git a/src/routes/validate/note_field.ts b/src/routes/validate/note_field.ts index 4892f22..7d34d98 100644 --- a/src/routes/validate/note_field.ts +++ b/src/routes/validate/note_field.ts @@ -1,15 +1,15 @@ -import joi from '@hapi/joi'; +import Joi from '@hapi/joi'; export default class NoteFieldValidate { private static note_field = { - name: joi.string() + name: Joi.string() .max(128), - qty: joi.number() + qty: Joi.number() }; static output (data) { - const {value, error} = joi.object({ + const {value, error} = Joi.object({ name: this.note_field.name, qty: this.note_field.qty }).validate(data, {stripUnknown: true}); diff --git a/src/routes/validate/res400.ts b/src/routes/validate/res400.ts new file mode 100644 index 0000000..5e032f7 --- /dev/null +++ b/src/routes/validate/res400.ts @@ -0,0 +1,3 @@ +export default function res400 (error, res) { + res.status(400).json({status: 'Invalid body format', details: error.details[0].message}); +} \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index d94cede..aa28304 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -1,41 +1,41 @@ -import joi from '@hapi/joi'; +import Joi from '@hapi/joi'; import IdValidate from './id'; export default class SampleValidate { private static sample = { - number: joi.string() + number: Joi.string() .max(128), - color: joi.string() + color: Joi.string() .max(128), - type: joi.string() + type: Joi.string() .max(128), - batch: joi.string() + batch: Joi.string() .max(128) .allow(''), - notes: joi.object({ - comment: joi.string() + notes: Joi.object({ + comment: Joi.string() .max(512), - sample_references: joi.array() - .items(joi.object({ + sample_references: Joi.array() + .items(Joi.object({ id: IdValidate.get(), - relation: joi.string() + relation: Joi.string() .max(128) })), - custom_fields: joi.object() - .pattern(/.*/, joi.alternatives() + custom_fields: Joi.object() + .pattern(/.*/, Joi.alternatives() .try( - joi.string().max(128), - joi.number(), - joi.boolean(), - joi.date() + Joi.string().max(128), + Joi.number(), + Joi.boolean(), + Joi.date() ) ) }) @@ -43,7 +43,7 @@ export default class SampleValidate { static input (data, param) { // validate data, param: new(everything required)/change(available attributes are validated) if (param === 'new') { - return joi.object({ + return Joi.object({ number: this.sample.number.required(), color: this.sample.color.required(), type: this.sample.type.required(), @@ -53,7 +53,14 @@ export default class SampleValidate { }).validate(data); } else if (param === 'change') { - return{error: 'Not implemented!', value: {}}; + return Joi.object({ + number: this.sample.number, + color: this.sample.color, + type: this.sample.type, + batch: this.sample.batch, + material_id: IdValidate.get(), + notes: this.sample.notes, + }).validate(data); } else { return{error: 'No parameter specified!', value: {}}; @@ -62,7 +69,7 @@ export default class SampleValidate { static output (data) { data = IdValidate.stringify(data); - const {value, error} = joi.object({ + const {value, error} = Joi.object({ _id: IdValidate.get(), number: this.sample.number, color: this.sample.color, diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index 150bf64..024d1a9 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -1,32 +1,32 @@ -import joi from '@hapi/joi'; +import Joi from '@hapi/joi'; import globals from '../../globals'; import IdValidate from './id'; export default class UserValidate { // validate input for user private static user = { - name: joi.string() + name: Joi.string() .alphanum() .lowercase() .max(128), - email: joi.string() + email: Joi.string() .email({minDomainSegments: 2}) .lowercase() .max(128), - pass: joi.string() + pass: Joi.string() .pattern(new RegExp('^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$')) .max(128), - level: joi.string() + level: Joi.string() .valid(...globals.levels), - location: joi.string() + location: Joi.string() .alphanum() .max(128), - device_name: joi.string() + device_name: Joi.string() .allow('') .max(128), }; @@ -35,7 +35,7 @@ export default class UserValidate { // validate input for user static input (data, param) { if (param === 'new') { - return joi.object({ + return Joi.object({ name: this.user.name.required(), email: this.user.email.required(), pass: this.user.pass.required(), @@ -45,7 +45,7 @@ export default class UserValidate { // validate input for user }).validate(data); } else if (param === 'change') { - return joi.object({ + return Joi.object({ name: this.user.name, email: this.user.email, pass: this.user.pass, @@ -54,7 +54,7 @@ export default class UserValidate { // validate input for user }).validate(data); } else if (param === 'changeadmin') { - return joi.object({ + return Joi.object({ name: this.user.name, email: this.user.email, pass: this.user.pass, @@ -70,7 +70,7 @@ export default class UserValidate { // validate input for user static output (data) { // validate output from database for needed properties, strip everything else data = IdValidate.stringify(data); - const {value, error} = joi.object({ + const {value, error} = Joi.object({ _id: IdValidate.get(), name: this.user.name, email: this.user.email, diff --git a/src/test/db.json b/src/test/db.json index 2d8a7d0..24daaca 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -61,7 +61,7 @@ "_id": {"$oid":"500000000000000000000002"}, "comment": "", "sample_references": [{ - "id": "400000000000000000000004", + "id": {"$oid":"400000000000000000000004"}, "relation": "granulate to sample" }], "custom_fields": { @@ -73,11 +73,12 @@ "_id": {"$oid":"500000000000000000000003"}, "comment": "", "sample_references": [{ - "id": "400000000000000000000003", + "id": {"$oid":"400000000000000000000003"}, "relation": "part to sample" }], "custom_fields": { - "not allowed for new applications": true + "not allowed for new applications": true, + "another_field": "is there" }, "__v": 0 } @@ -88,6 +89,12 @@ "name": "not allowed for new applications", "qty": 2, "__v": 0 + }, + { + "_id": {"$oid":"600000000000000000000002"}, + "name": "another_field", + "qty": 1, + "__v": 0 } ], "materials": [ From 16a1cf5ba8255e7537eb1fdb20ee42951bea38af Mon Sep 17 00:00:00 2001 From: VLE2FE Date: Fri, 8 May 2020 09:58:12 +0200 Subject: [PATCH 2/2] deleting a material is rejected if it is referenced by a sample --- api/material.yaml | 2 ++ api/sample.yaml | 2 +- package.json | 3 ++- src/routes/material.spec.ts | 16 ++++++++++++---- src/routes/material.ts | 21 +++++++++++++++------ src/routes/root.spec.ts | 2 +- src/routes/sample.spec.ts | 2 +- src/routes/sample.ts | 2 +- src/routes/template.spec.ts | 2 +- src/routes/user.spec.ts | 2 +- src/{helpers/test.ts => test/helper.ts} | 2 +- src/test/loadDev.ts | 12 ++++++++++++ 12 files changed, 50 insertions(+), 18 deletions(-) rename src/{helpers/test.ts => test/helper.ts} (98%) create mode 100644 src/test/loadDev.ts diff --git a/api/material.yaml b/api/material.yaml index a3b80da..5e8bc13 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -79,6 +79,8 @@ responses: 200: $ref: 'api.yaml#/components/responses/Ok' + 400: + $ref: 'api.yaml#/components/responses/400' 401: $ref: 'api.yaml#/components/responses/401' 403: diff --git a/api/sample.yaml b/api/sample.yaml index 8ba92af..e911d9c 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -123,7 +123,7 @@ /sample/notes/fields: get: - summary: TODO list all existing field names for custom notes fields + summary: list all existing field names for custom notes fields description: 'Auth: all, levels: read, write, maintain, dev, admin' tags: - /sample diff --git a/package.json b/package.json index d3f9e63..9a69ea2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "tsc": "tsc", "test": "mocha dist/**/**.spec.js", "start": "tsc && node dist/index.js || exit 1", - "dev": "nodemon -e ts,yaml --exec \"npm run start\"" + "dev": "nodemon -e ts,yaml --exec \"npm run start\"", + "loadDev": "node dist/test/loadDev.js" }, "keywords": [], "author": "", diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index aa9a484..dbc646b 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -1,6 +1,6 @@ import should from 'should/as-function'; import MaterialModel from '../models/material'; -import TestHelper from "../helpers/test"; +import TestHelper from "../test/helper"; describe('/material', () => { @@ -271,20 +271,28 @@ describe('/material', () => { it('deletes the material', done => { TestHelper.request(server, done, { method: 'delete', - url: '/material/100000000000000000000001', + url: '/material/100000000000000000000002', auth: {basic: 'janedoe'}, httpStatus: 200 }).end((err, res) => { if (err) return done(err); should(res.body).be.eql({status: 'OK'}); - MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => { + MaterialModel.findById('100000000000000000000002').lean().exec((err, data) => { if (err) return done(err); should(data).be.null(); done(); }); }); }); - it('rejects deleting a material referenced by samples'); + it('rejects deleting a material referenced by samples', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 400, + res: {status: 'Material still in use'} + }) + }); it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'delete', diff --git a/src/routes/material.ts b/src/routes/material.ts index 29362e2..292f02f 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -2,8 +2,10 @@ import express from 'express'; import MaterialValidate from './validate/material'; import MaterialModel from '../models/material' +import SampleModel from '../models/sample'; import IdValidate from './validate/id'; import res400 from './validate/res400'; +import mongoose from 'mongoose'; const router = express.Router(); @@ -69,14 +71,21 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; - MaterialModel.findByIdAndDelete(req.params.id).lean().exec((err, data) => { + // check if there are still samples referencing this material + SampleModel.find({'material_id': new mongoose.Types.ObjectId(req.params.id)}).lean().exec((err, data) => { if (err) return next(err); - if (data) { - res.json({status: 'OK'}) - } - else { - res.status(404).json({status: 'Not found'}); + if (data.length) { + return res.status(400).json({status: 'Material still in use'}); } + MaterialModel.findByIdAndDelete(req.params.id).lean().exec((err, data) => { + if (err) return next(err); + if (data) { + res.json({status: 'OK'}) + } + else { + res.status(404).json({status: 'Not found'}); + } + }); }); }); diff --git a/src/routes/root.spec.ts b/src/routes/root.spec.ts index 25be1ba..f8a803f 100644 --- a/src/routes/root.spec.ts +++ b/src/routes/root.spec.ts @@ -1,4 +1,4 @@ -import TestHelper from "../helpers/test"; +import TestHelper from "../test/helper"; describe('/', () => { diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 98017f0..aa01a39 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -2,7 +2,7 @@ import should from 'should/as-function'; import SampleModel from '../models/sample'; import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; -import TestHelper from "../helpers/test"; +import TestHelper from "../test/helper"; describe('/sample', () => { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 7415912..fe12ed0 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -10,7 +10,6 @@ import NoteFieldModel from '../models/note_field'; import IdValidate from './validate/id'; - const router = express.Router(); router.get('/samples', (req, res, next) => { @@ -129,6 +128,7 @@ router.post('/sample/new', async (req, res, next) => { delete sample.notes; sample.note_id = data._id; sample.user_id = req.authDetails.id; + console.log(sample); new SampleModel(sample).save((err, data) => { if (err) return next(err); res.json(SampleValidate.output(data.toObject())); diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index 6a4c7af..fa9361f 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -1,7 +1,7 @@ import should from 'should/as-function'; import TemplateTreatmentModel from '../models/treatment_template'; import TemplateMeasurementModel from '../models/measurement_template'; -import TestHelper from "../helpers/test"; +import TestHelper from "../test/helper"; describe('/template', () => { diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index a3a0ed9..e294cb2 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -1,6 +1,6 @@ import should from 'should/as-function'; import UserModel from '../models/user'; -import TestHelper from "../helpers/test"; +import TestHelper from "../test/helper"; describe('/user', () => { diff --git a/src/helpers/test.ts b/src/test/helper.ts similarity index 98% rename from src/helpers/test.ts rename to src/test/helper.ts index 6c2fa72..26cb5a5 100644 --- a/src/helpers/test.ts +++ b/src/test/helper.ts @@ -28,7 +28,7 @@ export default class TestHelper { server = require('../index'); db.drop(err => { // reset database if (err) return done(err); - db.loadJson(require('../test/db.json'), done); + db.loadJson(require('./db.json'), done); }); return server } diff --git a/src/test/loadDev.ts b/src/test/loadDev.ts new file mode 100644 index 0000000..690044d --- /dev/null +++ b/src/test/loadDev.ts @@ -0,0 +1,12 @@ +import db from '../db'; + +db.connect('dev', () => { + console.info('dropping data...'); + db.drop(() => { // reset database + console.info('loading data...'); + db.loadJson(require('./db.json'), () => { + console.info('done'); + process.exit(0); + }); + }); +});