diff --git a/api/condition.yaml b/api/condition.yaml index 5efa2ac..38bc56c 100644 --- a/api/condition.yaml +++ b/api/condition.yaml @@ -22,8 +22,8 @@ 500: $ref: 'api.yaml#/components/responses/500' put: - summary: TODO add/change condition - description: 'Auth: basic, levels: write, maintain, dev, admin' + summary: TODO change condition + description: 'Auth: basic, levels: write, maintain, dev, admin
Only maintain and admin are allowed to reference samples created by another user' tags: - /condition security: @@ -69,5 +69,35 @@ $ref: 'api.yaml#/components/responses/403' 404: $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + +/condition/new: + post: + summary: TODO add condition + description: 'Auth: basic, levels: write, maintain, dev, admin
Only maintain and admin are allowed to reference samples created by another user' + tags: + - /condition + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Condition' + responses: + 200: + description: condition details + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Condition' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' 500: $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/api/sample.yaml b/api/sample.yaml index e911d9c..4d2817b 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -42,7 +42,7 @@ $ref: 'api.yaml#/components/responses/500' put: summary: change sample - description: 'Auth: basic, levels: write, maintain, dev, admin, only maintain and admin are allowed to edit samples created by another user' + description: 'Auth: basic, levels: write, maintain, dev, admin
Only maintain and admin are allowed to edit samples created by another user' tags: - /sample security: @@ -72,7 +72,7 @@ $ref: 'api.yaml#/components/responses/500' delete: summary: delete sample - description: 'Auth: basic, levels: write, maintain, dev, admin, only maintain and admin are allowed to edit samples created by another user' + description: 'Auth: basic, levels: write, maintain, dev, admin
Only maintain and admin are allowed to edit samples created by another user' tags: - /sample security: diff --git a/api/schemas.yaml b/api/schemas.yaml index a7aa0e2..84722a5 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -120,6 +120,9 @@ Condition: properties: sample_id: $ref: 'api.yaml#/components/schemas/Id' + number: + type: string + example: B1 parameters: type: object treatment_template: diff --git a/src/index.ts b/src/index.ts index 3a87996..bb8e047 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,7 @@ app.use('/', require('./routes/sample')); app.use('/', require('./routes/material')); app.use('/', require('./routes/template')); app.use('/', require('./routes/user')); +app.use('/', require('./routes/condition')); // static files app.use('/static', express.static('static')); diff --git a/src/models/condition.ts b/src/models/condition.ts new file mode 100644 index 0000000..1e24daf --- /dev/null +++ b/src/models/condition.ts @@ -0,0 +1,12 @@ +import mongoose from 'mongoose'; +import SampleModel from './sample'; +import TreatmentTemplateModel from './treatment_template'; + +const ConditionSchema = new mongoose.Schema({ + sample_id: {type: mongoose.Schema.Types.ObjectId, ref: SampleModel}, + number: String, + parameters: mongoose.Schema.Types.Mixed, + treatment_template: {type: mongoose.Schema.Types.ObjectId, ref: TreatmentTemplateModel} +}); + +export default mongoose.model('condition', ConditionSchema); \ No newline at end of file diff --git a/src/routes/condition.spec.ts b/src/routes/condition.spec.ts new file mode 100644 index 0000000..2f17028 --- /dev/null +++ b/src/routes/condition.spec.ts @@ -0,0 +1,258 @@ +import should from 'should/as-function'; +import ConditionModel from '../models/condition'; +import TestHelper from "../test/helper"; + + +describe('/condition', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + + describe('GET /condition/id', () => { + it('returns the right condition', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/condition/700000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '700000000000000000000001', sample_id: '400000000000000000000001', number: 'B1', parameters: {material: 'copper', weeks: 3}, treatment_template: '200000000000000000000001'} + }); + }); + it('returns the right condition for an API key'); + it('rejects an invalid id'); + it('rejects an unknown id'); + it('rejects unauthorized requests'); + }); + + describe('POST /condition/new', () => { + it('returns the right condition', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('sample_id', '400000000000000000000002'); + should(res.body).have.property('number', 'B2'); + should(res.body).have.property('treatment_template', '200000000000000000000001'); + should(res.body).have.property('parameters'); + should(res.body.parameters).have.property('material', 'hot air'); + should(res.body.parameters).have.property('weeks', 10); + done(); + }); + }); + it('stores the condition', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }).end((err, res) => { + if (err) return done(err); + ConditionModel.findById(res.body._id).lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template', '__v'); + should(data).have.property('_id'); + should(data.sample_id.toString()).be.eql('400000000000000000000002'); + should(data).have.property('number', 'B2'); + should(data.treatment_template.toString()).be.eql('200000000000000000000001'); + should(data).have.property('parameters'); + should(data.parameters).have.property('material', 'hot air'); + should(data.parameters).have.property('weeks', 10); + done(); + }); + }); + }); + it('rejects an invalid sample id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '4000000000h0000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"sample_id" with value "4000000000h0000000000002" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects a missing sample id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '000000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Sample id not available'} + }); + }); + it('rejects an invalid treatment_template id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000h00000000001'}, + res: {status: 'Invalid body format', details: '"treatment_template" with value "200000000000h00000000001" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects a sample treatment_template which does not exist', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '000000000000000000000001'}, + res: {status: 'Treatment template not available'} + }); + }); + it('rejects a condition number already in use for this sample', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000001', number: 'B1', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Condition number already taken'} + }); + }); + it('rejects not specified parameters', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10, xx: 12}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"xx" is not allowed'} + }); + }); + it('rejects missing parameters', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air'}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"weeks" is required'} + }); + }); + it('rejects a parameter not in the value range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'xxx', weeks: 10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"material" must be one of [copper, hot air]'} + }); + }); + it('rejects a parameter below minimum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: -10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"weeks" must be larger than or equal to 1'} + }); + }); + it('rejects a parameter above maximum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 11}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"weeks" must be less than or equal to 10'} + }); + }); + it('rejects a missing sample id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"sample_id" is required'} + }); + }); + it('rejects a missing treatment_template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}}, + res: {status: 'Invalid body format', details: '"treatment_template" is required'} + }); + }); + it('rejects a missing number', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {sample_id: '400000000000000000000002', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'}, + res: {status: 'Invalid body format', details: '"number" is required'} + }); + }); + it('rejects adding a condition to the sample of an other user for a write user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {sample_id: '400000000000000000000003', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }); + }); + it('accepts adding a condition to the sample of an other user for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'sample_id', 'number', 'parameters', 'treatment_template'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('sample_id', '400000000000000000000002'); + should(res.body).have.property('number', 'B2'); + should(res.body).have.property('treatment_template', '200000000000000000000001'); + should(res.body).have.property('parameters'); + should(res.body.parameters).have.property('material', 'hot air'); + should(res.body.parameters).have.property('weeks', 10); + done(); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + auth: {basic: 'user'}, + httpStatus: 403, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/condition/new', + httpStatus: 401, + req: {sample_id: '400000000000000000000002', number: 'B2', parameters: {material: 'hot air', weeks: 10}, treatment_template: '200000000000000000000001'} + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/condition.ts b/src/routes/condition.ts new file mode 100644 index 0000000..0cf113d --- /dev/null +++ b/src/routes/condition.ts @@ -0,0 +1,65 @@ +import express from 'express'; +import mongoose from 'mongoose'; + +import ConditionValidate from './validate/condition'; +import ParametersValidate from './validate/parameters'; +import res400 from './validate/res400'; +import SampleModel from '../models/sample'; +import ConditionModel from '../models/condition'; +import TreatmentTemplateModel from '../models/treatment_template'; + + +const router = express.Router(); + +router.post('/condition/new', async (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const {error, value: condition} = ConditionValidate.input(req.body, 'new'); + if (error) return res400(error, res); + + if (!await sampleIdCheck(condition, req, res, next)) return; + if (!await numberCheck(condition, res, next)) return; + if (!await treatmentCheck(condition, res, next)) return; + + new ConditionModel(condition).save((err, data) => { + if (err) return next(err); + res.json(ConditionValidate.output(data.toObject())); + }); +}) + + +module.exports = router; + + +async function sampleIdCheck (condition, req, res, next) { // validate sample_id, returns false if invalid + const sampleData = await SampleModel.findById(condition.sample_id).lean().exec().catch(err => {next(err); return false;}) as any; + if (!sampleData) { // sample_id not found + res.status(400).json({status: 'Sample id not available'}); + return false + } + + if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return false; // sample does not belong to user + return true; +} + +async function numberCheck (condition, res, next) { // validate number, returns false if invalid + const data = await ConditionModel.find({sample_id: new mongoose.Types.ObjectId(condition.sample_id), number: condition.number}).lean().exec().catch(err => {next(err); return false;}) as any; + if (data.length) { + res.status(400).json({status: 'Condition number already taken'}); + return false; + } + return true; +} + +async function treatmentCheck (condition, res, next) { + const treatmentData = await TreatmentTemplateModel.findById(condition.treatment_template).lean().exec().catch(err => {next(err); return false;}) as any; + if (!treatmentData) { // sample_id not found + res.status(400).json({status: 'Treatment template not available'}); + return false + } + + // validate parameters + const {error, value: ignore} = ParametersValidate.input(condition.parameters, treatmentData.parameters); + if (error) {res400(error, res); return false;} + return true; +} \ No newline at end of file diff --git a/src/routes/sample.ts b/src/routes/sample.ts index fe12ed0..85619fa 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -150,7 +150,7 @@ module.exports = router; async function numberCheck (sample, res, next) { // validate number, returns false if invalid - const sampleData = await SampleModel.findOne({number: sample.number}).lean().exec().catch(err => { return next(err)}); + const sampleData = await SampleModel.findOne({number: sample.number}).lean().exec().catch(err => {next(err); return false;}); if (sampleData) { // found entry with sample number res.status(400).json({status: 'Sample number already taken'}); return false @@ -159,7 +159,7 @@ async function numberCheck (sample, res, next) { // validate number, returns fa } async function materialCheck (sample, res, next, id = sample.material_id) { // validate material_id and color, returns false if invalid - const materialData = await MaterialModel.findById(id).lean().exec().catch(err => {next(err);}) as any; + const materialData = await MaterialModel.findById(id).lean().exec().catch(err => {next(err); return false;}) as any; if (materialData instanceof Error) { return false; } diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index fa9361f..eea3ea4 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -121,7 +121,7 @@ describe('/template', () => { TemplateTreatmentModel.find({name: 'heat aging'}).lean().exec((err, data:any) => { if (err) return done(err); should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'parameters'); + should(data[0]).have.only.keys('_id', 'name', 'parameters', '__v'); should(data[0]).have.property('name', 'heat aging'); should(data[0]).have.property('parameters').have.lengthOf(1); should(data[0].parameters[0]).have.property('name', 'time'); @@ -443,7 +443,7 @@ describe('/template', () => { TemplateMeasurementModel.find({name: 'IR spectrum'}).lean().exec((err, data:any) => { if (err) return done(err); should(data).have.lengthOf(1); - should(data[0]).have.only.keys('_id', 'name', 'parameters'); + should(data[0]).have.only.keys('_id', 'name', 'parameters', '__v'); should(data[0]).have.property('name', 'IR spectrum'); should(data[0]).have.property('parameters').have.lengthOf(1); should(data[0].parameters[0]).have.property('name', 'data point table'); diff --git a/src/routes/validate/condition.ts b/src/routes/validate/condition.ts new file mode 100644 index 0000000..4c4673f --- /dev/null +++ b/src/routes/validate/condition.ts @@ -0,0 +1,57 @@ +import Joi from '@hapi/joi'; + +import IdValidate from './id'; + +export default class ConditionValidate { + private static condition = { + sample_id: IdValidate.get(), + + number: Joi.string() + .max(128), + + parameters: Joi.object() + .pattern(/.*/, Joi.alternatives() + .try( + Joi.string().max(128), + Joi.number(), + Joi.boolean() + ) + ), + + treatment_template: IdValidate.get() + } + + static input (data, param) { + if (param === 'new') { + return Joi.object({ + sample_id: this.condition.sample_id.required(), + number: this.condition.number.required(), + parameters: this.condition.parameters.required(), + treatment_template: this.condition.treatment_template.required() + }).validate(data); + } + else if (param === 'change') { + return Joi.object({ + sample_id: this.condition.sample_id, + number: this.condition.number, + parameters: this.condition.parameters, + treatment_template: this.condition.treatment_template + }).validate(data); + } + else { + return{error: 'No parameter specified!', value: {}}; + } + } + + static output (data) { + data = IdValidate.stringify(data); + const {value, error} = Joi.object({ + _id: IdValidate.get(), + sample_id: this.condition.sample_id, + number: this.condition.number, + parameters: this.condition.parameters, + treatment_template: this.condition.treatment_template + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/routes/validate/parameters.ts b/src/routes/validate/parameters.ts new file mode 100644 index 0000000..d14c6e2 --- /dev/null +++ b/src/routes/validate/parameters.ts @@ -0,0 +1,37 @@ +import Joi from '@hapi/joi'; + +export default class ParametersValidate { + static input (data, parameters) { + let joiObject = {}; + parameters.forEach(parameter => { + if (parameter.range.hasOwnProperty('values')) { + joiObject[parameter.name] = Joi.alternatives() + .try(Joi.string(), Joi.number(), Joi.boolean()) + .valid(...parameter.range.values) + .required(); + } + else if (parameter.range.hasOwnProperty('min') && parameter.range.hasOwnProperty('max')) { + joiObject[parameter.name] = Joi.number() + .min(parameter.range.min) + .max(parameter.range.max) + .required(); + } + else if (parameter.range.hasOwnProperty('min')) { + joiObject[parameter.name] = Joi.number() + .min(parameter.range.min) + .required(); + } + else if (parameter.range.hasOwnProperty('max')) { + joiObject[parameter.name] = Joi.number() + .max(parameter.range.max) + .required(); + } + else { + joiObject[parameter.name] = Joi.alternatives() + .try(Joi.string(), Joi.number(), Joi.boolean()) + .required(); + } + }); + return Joi.object(joiObject).validate(data); + } +} \ No newline at end of file diff --git a/src/test/db.json b/src/test/db.json index 24daaca..95ff0fc 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -183,6 +183,19 @@ "__v": 0 } ], + "conditions": [ + { + "_id": {"$oid":"700000000000000000000001"}, + "sample_id": {"$oid":"400000000000000000000001"}, + "number": "B1", + "parameters": { + "material": "copper", + "weeks": 3 + }, + "treatment_template": {"$oid":"200000000000000000000001"}, + "__v": 0 + } + ], "treatment_templates": [ { "_id": {"$oid":"200000000000000000000001"}, @@ -204,7 +217,8 @@ "max": 10 } } - ] + ], + "__v": 0 }, { "_id": {"$oid":"200000000000000000000002"}, @@ -214,7 +228,8 @@ "name": "material", "range": {} } - ] + ], + "__v": 0 } ], "measurement_templates": [ @@ -226,7 +241,8 @@ "name": "dpt", "range": {} } - ] + ], + "__v": 0 }, { "_id": {"$oid":"300000000000000000000002"}, @@ -246,7 +262,8 @@ "max": 0.5 } } - ] + ], + "__v": 0 } ], "users": [