diff --git a/api/sample.yaml b/api/sample.yaml index 30500b4..c699809 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -2,7 +2,7 @@ get: summary: all samples in overview description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: returns only samples with status 10 # TODO: methods /samples/new|deleted + x-doc: returns only samples with status 10 tags: - /sample responses: @@ -18,6 +18,30 @@ $ref: 'api.yaml#/components/responses/401' 500: $ref: 'api.yaml#/components/responses/500' + +/samples{group}: + parameters: + - $ref: 'api.yaml#/components/parameters/Group' + get: + summary: all new/deleted samples in overview + description: 'Auth: basic, levels: maintain, admin' + x-doc: returns only samples with status 0/-1 + tags: + - /sample + responses: + 200: + description: samples overview + content: + application/json: + schema: + type: array + items: + $ref: 'api.yaml#/components/schemas/SampleRefs' + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' + /sample/{id}: parameters: - $ref: 'api.yaml#/components/parameters/Id' @@ -130,7 +154,7 @@ get: summary: list all existing field names for custom notes fields description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: integrity has to be ensured # TODO: implement mechanism to regularly check note_fields + x-doc: integrity has to be ensured tags: - /sample responses: diff --git a/src/api.ts b/src/api.ts index 228f166..625e738 100644 --- a/src/api.ts +++ b/src/api.ts @@ -20,7 +20,7 @@ export default class api { apiDoc = doc; apiDoc.paths = apiDoc.paths.allOf.reduce((s, e) => Object.assign(s, e)); // bundle routes apiDoc = this.resolveXDoc(apiDoc); - oasParser.validate(apiDoc, (err, api) => { + oasParser.validate(apiDoc, (err, api) => { // validate oas schema if (err) { console.error(err); } @@ -35,8 +35,8 @@ export default class api { private static resolveXDoc (doc) { // resolve x-doc properties recursively Object.keys(doc).forEach(key => { - if (doc[key] !== null && doc[key].hasOwnProperty('x-doc')) { - doc[key].description += this.addHtml(doc[key]['x-doc']); + if (doc[key] !== null && doc[key].hasOwnProperty('x-doc')) { // add x-doc to description, is styled via css + doc[key].description += '
docs' + doc[key]['x-doc'] + '
'; } else if (typeof doc[key] === 'object' && doc[key] !== null) { // go deeper into recursion doc[key] = this.resolveXDoc(doc[key]); @@ -44,8 +44,4 @@ export default class api { }); return doc; } - - private static addHtml (text) { // add docs HTML - return '
docs' + text + '
'; - } } \ No newline at end of file diff --git a/src/db.ts b/src/db.ts index 89c3183..c1d1fbb 100644 --- a/src/db.ts +++ b/src/db.ts @@ -13,7 +13,7 @@ export default class db { mode: null, }; - static connect (mode = '', done: Function = () => {}) { // set mode to test for unit/integration tests, otherwise skip parameter. done is also only needed for testing + static connect (mode = '', done: Function = () => {}) { // set mode to test for unit/integration tests, otherwise skip parameters. done is also only needed for testing if (this.state.db) return done(); // db is already connected // find right connection url @@ -84,9 +84,9 @@ export default class db { } static loadJson (json, done: Function = () => {}) { // insert given JSON data into db, uses core mongodb methods - if (!this.state.db || !json.hasOwnProperty('collections') || json.collections.length === 0) { + if (!this.state.db || !json.hasOwnProperty('collections') || json.collections.length === 0) { // no db connection or nothing to load return done(); - } // no db connection or nothing to load + } let loadCounter = 0; // count number of loaded collections to know when to return done() Object.keys(json.collections).forEach(collectionName => { // create each collection @@ -103,10 +103,10 @@ export default class db { private static oidResolve (object: any) { // resolve $oid fields to actual ObjectIds recursively Object.keys(object).forEach(key => { - if (object[key] !== null && object[key].hasOwnProperty('$oid')) { + if (object[key] !== null && object[key].hasOwnProperty('$oid')) { // found oid, replace object[key] = mongoose.Types.ObjectId(object[key].$oid); } - else if (typeof object[key] === 'object' && object[key] !== null) { + else if (typeof object[key] === 'object' && object[key] !== null) { // deeper into recursion object[key] = this.oidResolve(object[key]); } }); diff --git a/src/helpers/authorize.ts b/src/helpers/authorize.ts index c2404a4..21d43d5 100644 --- a/src/helpers/authorize.ts +++ b/src/helpers/authorize.ts @@ -63,7 +63,7 @@ function basic (req, next): any { // checks basic auth and returns changed user if (data.length === 1) { // one user found bcrypt.compare(auth.pass, data[0].pass, (err, res) => { // check password if (err) return next(err); - if (res === true) { + if (res === true) { // password correct resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString(), location: data[0].location}); } else { @@ -84,7 +84,7 @@ function basic (req, next): any { // checks basic auth and returns changed user function key (req, next): any { // checks API key and returns changed user object return new Promise(resolve => { - if (req.query.key !== undefined) { + if (req.query.key !== undefined) { // key available UserModel.find({key: req.query.key}).lean().exec( (err, data: any) => { // find user if (err) return next(err); if (data.length === 1) { // one user found diff --git a/src/helpers/mail.ts b/src/helpers/mail.ts index 792f35f..a3d79c1 100644 --- a/src/helpers/mail.ts +++ b/src/helpers/mail.ts @@ -1,6 +1,6 @@ import axios from 'axios'; -// sends an email +// sends an email using the BIC service export default (mailAddress, subject, content, f) => { // callback, executed empty or with error if (process.env.NODE_ENV === 'production') { diff --git a/src/index.ts b/src/index.ts index c40ed24..362f5cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import mongoSanitize from 'mongo-sanitize'; import api from './api'; import db from './db'; -// TODO: overall commenting/documentation review + // tell if server is running in debug or production environment console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); diff --git a/src/routes/condition.ts b/src/routes/condition.ts index 71dc27b..f66d10a 100644 --- a/src/routes/condition.ts +++ b/src/routes/condition.ts @@ -37,13 +37,14 @@ router.put('/condition/' + IdValidate.parameter(), async (req, res, next) => { if (!data) { res.status(404).json({status: 'Not found'}); } + // add properties needed for sampleIdCheck condition.treatment_template = data.treatment_template; condition.sample_id = data.sample_id; if (!await sampleIdCheck(condition, req, res, next)) return; if (condition.parameters) { condition.parameters = _.assign({}, data.parameters, condition.parameters); - if (!_.isEqual(condition.parameters, data.parameters)) { + if (!_.isEqual(condition.parameters, data.parameters)) { // parameters did not change condition.status = 0; } } @@ -83,7 +84,7 @@ router.post('/condition/new', async (req, res, next) => { condition.number = await numberGenerate(condition, treatmentData, next); if (!condition.number) return; - condition.status = 0; + condition.status = 0; // set status to new await new ConditionModel(condition).save((err, data) => { if (err) return next(err); res.json(ConditionValidate.output(data.toObject())); @@ -105,8 +106,8 @@ async function sampleIdCheck (condition, req, res, next) { // validate sample_i return true; } -async function numberGenerate (condition, treatmentData, next) { // validate number, returns false if invalid - const conditionData = await ConditionModel +async function numberGenerate (condition, treatmentData, next) { // generate number, returns false on error + const conditionData = await ConditionModel // find condition with highest number belonging to the same sample .find({sample_id: condition.sample_id, number: new RegExp('^' + treatmentData.number_prefix + '[0-9]+$', 'm')}) .sort({number: -1}) .limit(1) @@ -114,7 +115,7 @@ async function numberGenerate (condition, treatmentData, next) { // validate nu .exec() .catch(err => next(err)) as any; if (conditionData instanceof Error) return false; - return treatmentData.number_prefix + (conditionData.length > 0 ? Number(conditionData[0].number.replace(/[^0-9]+/g, '')) + 1 : 1); + return treatmentData.number_prefix + (conditionData.length > 0 ? Number(conditionData[0].number.replace(/[^0-9]+/g, '')) + 1 : 1); // return new number } async function treatmentCheck (condition, param, res, next) { // validate treatment template, returns false if invalid, otherwise template data diff --git a/src/routes/material.ts b/src/routes/material.ts index 2de50a8..dd89985 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -33,7 +33,6 @@ router.get('/materials/:group(new|deleted)', (req, res, next) => { } MaterialModel.find({status: status}).lean().exec((err, data) => { if (err) return next(err); - console.log(data); res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors }); }); @@ -68,7 +67,7 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { // check for changes if (!_.isEqual(_.pick(IdValidate.stringify(materialData), _.keys(material)), material)) { - material.status = 0; + material.status = 0; // set status to new } await MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).lean().exec((err, data) => { @@ -102,13 +101,12 @@ router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { router.post('/material/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; - // validate input const {error, value: material} = MaterialValidate.input(req.body, 'new'); if (error) return res400(error, res); if (!await nameCheck(material, res, next)) return; - material.status = 0; + material.status = 0; // set status to new await new MaterialModel(material).save((err, data) => { if (err) return next(err); res.json(MaterialValidate.output(data.toObject())); @@ -120,7 +118,7 @@ module.exports = router; async function nameCheck (material, res, next) { // check if name was already taken - const materialData = await MaterialModel.findOne({name: material.name}).lean().exec().catch(err => {next(err); return false;}) as any; + const materialData = await MaterialModel.findOne({name: material.name}).lean().exec().catch(err => next(err)) as any; if (materialData instanceof Error) return false; if (materialData) { // could not find material_id res.status(400).json({status: 'Material name already taken'}); diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index bb69b3f..eda839e 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -36,16 +36,20 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { if (!data) { res.status(404).json({status: 'Not found'}); } + // add properties needed for conditionIdCheck measurement.measurement_template = data.measurement_template; measurement.condition_id = data.condition_id; if (!await conditionIdCheck(measurement, req, res, next)) return; + + // check for changes if (measurement.values) { measurement.values = _.assign({}, data.values, measurement.values); if (!_.isEqual(measurement.values, data.values)) { - measurement.status = 0; + measurement.status = 0; // set status to new } } + if (!await templateCheck(measurement, 'change', res, next)) return; await MeasurementModel.findByIdAndUpdate(req.params.id, measurement, {new: true}).lean().exec((err, data) => { if (err) return next(err); @@ -99,7 +103,7 @@ async function conditionIdCheck (measurement, req, res, next) { // validate con return true; } -async function templateCheck (measurement, param, res, next) { // validate measurement_template and values +async function templateCheck (measurement, param, res, next) { // validate measurement_template and values, param for new/change const templateData = await MeasurementTemplateModel.findById(measurement.measurement_template).lean().exec().catch(err => {next(err); return false;}) as any; if (!templateData) { // template not found res.status(400).json({status: 'Measurement template not available'}); @@ -108,7 +112,6 @@ async function templateCheck (measurement, param, res, next) { // validate meas // validate values const {error, value: ignore} = ParametersValidate.input(measurement.values, templateData.parameters, param); - console.log(error); if (error) {res400(error, res); return false;} return true; } \ No newline at end of file diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 581ee0c..e1a93d8 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -4,7 +4,6 @@ import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; import TestHelper from "../test/helper"; -// TODO: think again which parameters are required at POST describe('/sample', () => { let server; @@ -23,16 +22,16 @@ describe('/sample', () => { if (err) return done(err); const json = require('../test/db.json'); should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === 10).length); - should(res.body).matchEach(material => { - should(material).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); - should(material).have.property('_id').be.type('string'); - should(material).have.property('number').be.type('string'); - should(material).have.property('type').be.type('string'); - should(material).have.property('color').be.type('string'); - should(material).have.property('batch').be.type('string'); - should(material).have.property('material_id').be.type('string'); - should(material).have.property('note_id'); - should(material).have.property('user_id').be.type('string'); + should(res.body).matchEach(sample => { + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); + should(sample).have.property('_id').be.type('string'); + should(sample).have.property('number').be.type('string'); + should(sample).have.property('type').be.type('string'); + should(sample).have.property('color').be.type('string'); + should(sample).have.property('batch').be.type('string'); + should(sample).have.property('material_id').be.type('string'); + should(sample).have.property('note_id'); + should(sample).have.property('user_id').be.type('string'); }); done(); }); @@ -70,6 +69,94 @@ describe('/sample', () => { }); }); + describe('GET /samples/{group}', () => { + it('returns all new samples', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/new', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + let asyncCounter = res.body.length; + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === 0).length); + should(res.body).matchEach(sample => { + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); + should(sample).have.property('_id').be.type('string'); + should(sample).have.property('number').be.type('string'); + should(sample).have.property('type').be.type('string'); + should(sample).have.property('color').be.type('string'); + should(sample).have.property('batch').be.type('string'); + should(sample).have.property('material_id').be.type('string'); + should(sample).have.property('note_id'); + should(sample).have.property('user_id').be.type('string'); + SampleModel.findById(sample._id).lean().exec((err, data) => { + should(data).have.property('status', 0); + if (--asyncCounter === 0) { + done(); + } + }); + }); + done(); + }); + }); + it('returns all deleted samples', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/deleted', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + const json = require('../test/db.json'); + let asyncCounter = res.body.length; + should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === -1).length); + should(res.body).matchEach(sample => { + should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'material_id', 'note_id', 'user_id'); + should(sample).have.property('_id').be.type('string'); + should(sample).have.property('number').be.type('string'); + should(sample).have.property('type').be.type('string'); + should(sample).have.property('color').be.type('string'); + should(sample).have.property('batch').be.type('string'); + should(sample).have.property('material_id').be.type('string'); + should(sample).have.property('note_id'); + should(sample).have.property('user_id').be.type('string'); + SampleModel.findById(sample._id).lean().exec((err, data) => { + should(data).have.property('status', -1); + if (--asyncCounter === 0) { + done(); + } + }); + }); + done(); + }); + }); + it('rejects requests from a write user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/new', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/new', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/samples/new', + httpStatus: 401 + }); + }); + }); + describe('PUT /sample/{id}', () => { it('returns the right sample', done => { TestHelper.request(server, done, { @@ -194,12 +281,10 @@ describe('/sample', () => { }).end(err => { if (err) return done(err); NoteFieldModel.findOne({name: 'not allowed for new applications'}).lean().exec((err, data) => { - console.log(data); if (err) return done(err); should(data).have.property('qty', 1); NoteFieldModel.findOne({name: 'field1'}).lean().exec((err, data) => { if (err) return done(err); - console.log(data); should(data).have.property('qty', 1); done(); }); @@ -233,7 +318,6 @@ describe('/sample', () => { if (err) return done (err); NoteModel.findById(res.body.note_id).lean().exec((err, data) => { if (err) return done (err); - console.log(data); should(data).not.be.null(); should(data).have.property('comment', 'Stoff gesperrt'); should(data).have.property('sample_references').have.lengthOf(0); @@ -448,7 +532,6 @@ describe('/sample', () => { setTimeout(() => { // background action takes some time before we can check NoteModel.findById('500000000000000000000003').lean().exec((err, data: any) => { if (err) return done(err); - console.log(data); should(data).have.property('sample_references').with.lengthOf(1); should(data.sample_references[0].id.toString()).be.eql('400000000000000000000003'); should(data.sample_references[0]).have.property('relation', 'part to sample'); diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 38e2282..43acd6e 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -22,6 +22,22 @@ router.get('/samples', (req, res, next) => { }) }); +router.get('/samples/:group(new|deleted)', (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + let status; + switch (req.params.group) { + case 'new': status = 0; + break; + case 'deleted': status = -1; + break; + } + SampleModel.find({status: status}).lean().exec((err, data) => { + if (err) return next(err); + res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors + }) +}); + router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; @@ -33,6 +49,7 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!sampleData) { return res.status(404).json({status: 'Not found'}); } + // only maintain and admin are allowed to edit other user's data if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; @@ -48,12 +65,12 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (sampleData.note_id !== null) { // old notes data exists const data = await NoteModel.findById(sampleData.note_id).lean().exec().catch(err => {next(err);}) as any; if (data instanceof Error) return; - newNotes = !_.isEqual(_.pick(IdValidate.stringify(data), _.keys(sample.notes)), sample.notes); + newNotes = !_.isEqual(_.pick(IdValidate.stringify(data), _.keys(sample.notes)), sample.notes); // check if notes were changed if (newNotes) { if (data.hasOwnProperty('custom_fields')) { // update note_fields customFieldsChange(Object.keys(data.custom_fields), -1); } - NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec(err => { // delete old notes + await NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec(err => { // delete old notes if (err) return console.error(err); }); } @@ -74,7 +91,8 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!_.isEqual(_.pick(IdValidate.stringify(sampleData), _.keys(sample)), _.omit(sample, ['notes']))) { sample.status = 0; } - SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).lean().exec((err, data) => { + + await SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).lean().exec((err, data) => { if (err) return next(err); res.json(SampleValidate.output(data)); }); @@ -90,12 +108,13 @@ router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!sampleData) { return res.status(404).json({status: 'Not found'}); } + // only maintain and admin are allowed to edit other user's data if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; - SampleModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec(err => { // set sample status + await SampleModel.findByIdAndUpdate(req.params.id, {status: -1}).lean().exec(err => { // set sample status if (err) return next(err); - if (sampleData.note_id !== null) { + if (sampleData.note_id !== null) { // handle notes NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { // find notes to update note_fields if (err) return next(err); if (data.hasOwnProperty('custom_fields')) { // update note_fields @@ -124,15 +143,15 @@ router.post('/sample/new', async (req, res, next) => { customFieldsChange(Object.keys(sample.notes.custom_fields), 1); } - sample.status = 0; + sample.status = 0; // set status to new sample.number = await numberGenerate(sample, req, res, next); if (!sample.number) return; - new NoteModel(sample.notes).save((err, data) => { + + await new NoteModel(sample.notes).save((err, data) => { // save notes if (err) return next(err); delete sample.notes; sample.note_id = data._id; sample.user_id = req.authDetails.id; - console.log(sample); new SampleModel(sample).save((err, data) => { if (err) return next(err); res.json(SampleValidate.output(data.toObject())); @@ -153,7 +172,7 @@ router.get('/sample/notes/fields', (req, res, next) => { module.exports = router; -async function numberGenerate (sample, req, res, next) { // validate number, returns false if invalid +async function numberGenerate (sample, req, res, next) { // generate number, returns false on error const sampleData = await SampleModel .find({number: new RegExp('^' + req.authDetails.location + '[0-9]+$', 'm')}) .lean() @@ -180,7 +199,8 @@ async function materialCheck (sample, res, next, id = sample.material_id) { // function sampleRefCheck (sample, res, next) { // validate sample_references, resolves false for invalid reference return new Promise(resolve => { if (sample.notes.sample_references.length > 0) { // there are sample_references - let referencesCount = sample.notes.sample_references.length; + let referencesCount = sample.notes.sample_references.length; // count to keep track of running async operations + sample.notes.sample_references.forEach(reference => { SampleModel.findById(reference.id).lean().exec((err, data) => { if (err) {next(err); resolve(false)} @@ -189,7 +209,7 @@ function sampleRefCheck (sample, res, next) { // validate sample_references, re return resolve(false); } referencesCount --; - if (referencesCount <= 0) { + if (referencesCount <= 0) { // all async requests done resolve(true); } }); @@ -201,7 +221,7 @@ function sampleRefCheck (sample, res, next) { // validate sample_references, re }); } -function customFieldsChange (fields, amount) { +function customFieldsChange (fields, amount) { // update custom_fields and respective quantities fields.forEach(field => { NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: amount}}, {new: true}).lean().exec((err, data: any) => { // check if field exists if (err) return console.error(err); diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index c2e6fcd..b1a3450 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -200,7 +200,6 @@ describe('/template', () => { httpStatus: 200, req: {parameters: [{name: 'time', range: {type: 'array'}}]} }).end((err, res) => { - console.log(res.body); if (err) return done(err); should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, number_prefix: 'A', parameters: [{name: 'time', range: {type: 'array'}}]}); done(); diff --git a/src/routes/template.ts b/src/routes/template.ts index 28caf2b..a8f7413 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -14,7 +14,7 @@ const router = express.Router(); router.get('/template/:collection(measurements|treatments)', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; - req.params.collection = req.params.collection.replace(/s$/g, ''); + req.params.collection = req.params.collection.replace(/s$/g, ''); // remove trailing s model(req).find({}).lean().exec((err, data) => { if (err) next (err); res.json(_.compact(data.map(e => TemplateValidate.output(e, req.params.collection)))); // validate all and filter null values from validation errors @@ -52,8 +52,8 @@ router.put('/template/:collection(measurement|treatment)/' + IdValidate.paramete } if (!_.isEqual(_.pick(templateData, _.keys(template)), template)) { // data was changed - template.version = templateData.version + 1; - await new (model(req))(_.assign({}, _.omit(templateData, ['_id', '__v']), template)).save((err, data) => { + template.version = templateData.version + 1; // increase version + await new (model(req))(_.assign({}, _.omit(templateData, ['_id', '__v']), template)).save((err, data) => { // save new template, fill with old properties if (err) next (err); res.json(TemplateValidate.output(data.toObject(), req.params.collection)); }); @@ -73,7 +73,7 @@ router.post('/template/:collection(measurement|treatment)/new', async (req, res, if (!await numberPrefixCheck(template, req, res, next)) return; } - template.version = 1; + template.version = 1; // set template version await new (model(req))(template).save((err, data) => { if (err) next (err); res.json(TemplateValidate.output(data.toObject(), req.params.collection)); @@ -84,7 +84,7 @@ router.post('/template/:collection(measurement|treatment)/new', async (req, res, module.exports = router; -async function numberPrefixCheck (template, req, res, next) { +async function numberPrefixCheck (template, req, res, next) { // check if number_prefix is available const data = await model(req).findOne({number_prefix: template.number_prefix}).lean().exec().catch(err => {next(err); return false;}) as any; if (data) { res.status(400).json({status: 'Number prefix already taken'}); @@ -93,6 +93,6 @@ async function numberPrefixCheck (template, req, res, next) { return true; } -function model (req) { +function model (req) { // return right template model return req.params.collection === 'treatment' ? TemplateTreatmentModel : TemplateMeasurementModel; } \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts index 5a2485c..4fb2c0f 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -20,14 +20,10 @@ router.get('/users', (req, res) => { }); router.get('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new. See https://forbeslindesay.github.io/express-route-tester/ for the generated regex - req.params.username = req.params[0]; if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; - let username = req.authDetails.username; - if (req.params.username !== undefined) { - if (!req.auth(res, ['admin'], 'basic')) return; - username = req.params.username; - } + const username = getUsername(req, res); + if (!username) return; UserModel.findOne({name: username}).lean().exec( (err, data:any) => { if (err) return next(err); if (data) { @@ -39,14 +35,13 @@ router.get('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi }); }); -router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new - req.params.username = req.params[0]; +router.put('/user:username([/](?!key|new).?*|/?)', async (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; - let username = req.authDetails.username; - if (req.params.username !== undefined) { - if (!req.auth(res, ['admin'], 'basic')) return; - username = req.params.username; - } + + const username = getUsername(req, res); + if (!username) return; + console.log(username); + const {error, value: user} = UserValidate.input(req.body, 'change' + (req.authDetails.level === 'admin'? 'admin' : '')); if (error) return res400(error, res); @@ -56,45 +51,25 @@ router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi // check that user does not already exist if new name was specified if (user.hasOwnProperty('name') && user.name !== username) { - UserModel.find({name: user.name}).lean().exec( (err, data:any) => { - if (err) return next(err); - if (data.length > 0 || UserValidate.isSpecialName(user.name)) { - res.status(400).json({status: 'Username already taken'}); - return; - } + if (!await usernameCheck(user.name, res, next)) return; + } - UserModel.findOneAndUpdate({name: username}, user, {new: true}).lean().exec( (err, data:any) => { - if (err) return next(err); - if (data) { - res.json(UserValidate.output(data)); - } - else { - res.status(404).json({status: 'Not found'}); - } - }); - }); - } - else { - UserModel.findOneAndUpdate({name: username}, user, {new: true}).lean().exec( (err, data:any) => { - if (err) return next(err); - if (data) { - res.json(UserValidate.output(data)); // validate all and filter null values from validation errors - } - else { - res.status(404).json({status: 'Not found'}); - } - }); - } + await UserModel.findOneAndUpdate({name: username}, user, {new: true}).lean().exec( (err, data:any) => { + if (err) return next(err); + if (data) { + res.json(UserValidate.output(data)); + } + else { + res.status(404).json({status: 'Not found'}); + } + }); }); router.delete('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new. See https://forbeslindesay.github.io/express-route-tester/ for the generated regex - req.params.username = req.params[0]; if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return; - let username = req.authDetails.username; - if (req.params.username !== undefined) { - if (!req.auth(res, ['admin'], 'basic')) return; - username = req.params.username; - } + + const username = getUsername(req, res); + if (!username) return; UserModel.findOneAndDelete({name: username}).lean().exec( (err, data:any) => { if (err) return next(err); @@ -116,7 +91,7 @@ router.get('/user/key', (req, res, next) => { }); }); -router.post('/user/new', (req, res, next) => { +router.post('/user/new', async (req, res, next) => { if (!req.auth(res, ['admin'], 'basic')) return; // validate input @@ -124,20 +99,14 @@ router.post('/user/new', (req, res, next) => { if (error) return res400(error, res); // check that user does not already exist - UserModel.find({name: user.name}).lean().exec( (err, data:any) => { - if (err) return next(err); - if (data.length > 0 || UserValidate.isSpecialName(user.name)) { - res.status(400).json({status: 'Username already taken'}); - return; - } + if (!await usernameCheck(user.name, res, next)) return; - user.key = mongoose.Types.ObjectId(); // use object id as unique API key - bcrypt.hash(user.pass, 10, (err, hash) => { // password hashing - user.pass = hash; - new UserModel(user).save((err, data) => { // store user - if (err) return next(err); - res.json(UserValidate.output(data.toObject())); - }); + user.key = mongoose.Types.ObjectId(); // use object id as unique API key + bcrypt.hash(user.pass, 10, (err, hash) => { // password hashing + user.pass = hash; + new UserModel(user).save((err, data) => { // store user + if (err) return next(err); + res.json(UserValidate.output(data.toObject())); }); }); }); @@ -147,11 +116,14 @@ router.post('/user/passreset', (req, res, next) => { UserModel.find({name: req.body.name, email: req.body.email}).lean().exec( (err, data: any) => { if (err) return next(err); if (data.length === 1) { // it exists - const newPass = Math.random().toString(36).substring(2); + const newPass = Math.random().toString(36).substring(2); // generate temporary password bcrypt.hash(newPass, 10, (err, hash) => { // password hashing if (err) return next(err); + UserModel.findByIdAndUpdate(data[0]._id, {pass: hash}, err => { // write new password if (err) return next(err); + + // send email mail(data[0].email, 'Your new password for the DFOP database', 'Hi,

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

' + newPass + '

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

Have a nice day.

The DFOP team', err => { if (err) return next(err); res.json({status: 'OK'}); @@ -166,4 +138,27 @@ router.post('/user/passreset', (req, res, next) => { }); -module.exports = router; \ No newline at end of file +module.exports = router; + +function getUsername (req, res) { // returns username or false if action is not allowed + req.params.username = req.params[0]; // because of path regex + if (req.params.username !== undefined) { // different username than request user + if (!req.auth(res, ['admin'], 'basic')) return false; + return req.params.username; + } + else { + return req.authDetails.username; + } +} + +async function usernameCheck (name, res, next) { // check if username is already taken + const userData = await UserModel.findOne({name: name}).lean().exec().catch(err => next(err)) as any; + if (userData instanceof Error) return false; + console.log(userData); + console.log(UserValidate.isSpecialName(name)); + if (userData || UserValidate.isSpecialName(name)) { + res.status(400).json({status: 'Username already taken'}); + return false; + } + return true; +} \ No newline at end of file diff --git a/src/routes/validate/condition.ts b/src/routes/validate/condition.ts index 491c318..d752ff3 100644 --- a/src/routes/validate/condition.ts +++ b/src/routes/validate/condition.ts @@ -18,7 +18,7 @@ export default class ConditionValidate { ) } - static input (data, param) { + static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { return Joi.object({ sample_id: IdValidate.get().required(), @@ -36,7 +36,7 @@ export default class ConditionValidate { } } - static output (data) { + static output (data) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); const {value, error} = Joi.object({ _id: IdValidate.get(), diff --git a/src/routes/validate/id.ts b/src/routes/validate/id.ts index a9bb70a..6b7b677 100644 --- a/src/routes/validate/id.ts +++ b/src/routes/validate/id.ts @@ -3,11 +3,11 @@ import Joi from '@hapi/joi'; export default class IdValidate { private static id = Joi.string().pattern(new RegExp('[0-9a-f]{24}')).length(24); - static get () { + static get () { // return joi validation return this.id; } - static valid (id) { + static valid (id) { // validate id return this.id.validate(id).error === undefined; } @@ -15,11 +15,14 @@ export default class IdValidate { return ':id([0-9a-f]{24})'; } - static stringify (data) { + static stringify (data) { // convert all ObjectID objects to plain strings Object.keys(data).forEach(key => { - if (data[key] !== null && data[key].hasOwnProperty('_bsontype') && data[key]._bsontype === 'ObjectID') { + if (data[key] !== null && data[key].hasOwnProperty('_bsontype') && data[key]._bsontype === 'ObjectID') { // stringify id data[key] = data[key].toString(); } + else if (typeof data[key] === 'object' && data[key] !== null) { // deeper into recursion + data[key] = this.stringify(data[key]); + } }); return data; } diff --git a/src/routes/validate/material.ts b/src/routes/validate/material.ts index b09c494..c92f440 100644 --- a/src/routes/validate/material.ts +++ b/src/routes/validate/material.ts @@ -40,7 +40,7 @@ export default class MaterialValidate { // validate input for material })) }; - static input (data, param) { // validate data, param: new(everything required)/change(available attributes are validated) + static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { return joi.object({ name: this.material.name.required(), @@ -68,7 +68,7 @@ export default class MaterialValidate { // validate input for material } } - static output (data) { // validate output from database for needed properties, strip everything else + static output (data) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); const {value, error} = joi.object({ _id: IdValidate.get(), diff --git a/src/routes/validate/measurement.ts b/src/routes/validate/measurement.ts index 0efaaea..21b38a2 100644 --- a/src/routes/validate/measurement.ts +++ b/src/routes/validate/measurement.ts @@ -15,7 +15,7 @@ export default class MeasurementValidate { ) }; - static input (data, param) { + static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { return Joi.object({ condition_id: IdValidate.get().required(), @@ -33,7 +33,7 @@ export default class MeasurementValidate { } } - static output (data) { + static output (data) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); const {value, error} = Joi.object({ _id: IdValidate.get(), diff --git a/src/routes/validate/note_field.ts b/src/routes/validate/note_field.ts index 7d34d98..68856c9 100644 --- a/src/routes/validate/note_field.ts +++ b/src/routes/validate/note_field.ts @@ -8,7 +8,7 @@ export default class NoteFieldValidate { qty: Joi.number() }; - static output (data) { + static output (data) { // validate output and strip unwanted properties, returns null if not valid const {value, error} = Joi.object({ name: this.note_field.name, qty: this.note_field.qty diff --git a/src/routes/validate/parameters.ts b/src/routes/validate/parameters.ts index d855815..79e62ef 100644 --- a/src/routes/validate/parameters.ts +++ b/src/routes/validate/parameters.ts @@ -4,7 +4,7 @@ export default class ParametersValidate { static input (data, parameters, param) { // data to validate, parameters from template, param: 'new', 'change' let joiObject = {}; parameters.forEach(parameter => { - if (parameter.range.hasOwnProperty('values')) { + if (parameter.range.hasOwnProperty('values')) { // append right validation method according to parameter joiObject[parameter.name] = Joi.alternatives() .try(Joi.string().max(128), Joi.number(), Joi.boolean()) .valid(...parameter.range.values); diff --git a/src/routes/validate/res400.ts b/src/routes/validate/res400.ts index 5e032f7..e4595c8 100644 --- a/src/routes/validate/res400.ts +++ b/src/routes/validate/res400.ts @@ -1,3 +1,5 @@ +// respond with 400 and include error details from the joi validation + export default function res400 (error, res) { res.status(400).json({status: 'Invalid body format', details: error.details[0].message}); } \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 9373152..1b23cb1 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -41,7 +41,7 @@ export default class SampleValidate { }) }; - static input (data, param) { // validate data, param: new(everything required)/change(available attributes are validated) + static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { return Joi.object({ color: this.sample.color.required(), @@ -65,7 +65,7 @@ export default class SampleValidate { } } - static output (data) { + static output (data) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); const {value, error} = Joi.object({ _id: IdValidate.get(), diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts index 269e1a2..571f48c 100644 --- a/src/routes/validate/template.ts +++ b/src/routes/validate/template.ts @@ -43,7 +43,7 @@ export default class TemplateValidate { ) }; - static input (data, param, template) { // validate data, param: new(everything required)/change(available attributes are validated) + static input (data, param, template) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { if (template === 'treatment') { return Joi.object({ @@ -79,10 +79,10 @@ export default class TemplateValidate { } } - static output (data, template) { // validate output from database for needed properties, strip everything else + static output (data, template) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); let joiObject; - if (template === 'treatment') { + if (template === 'treatment') { // differentiate between measurement and treatment (has number_prefix) template joiObject = { _id: IdValidate.get(), name: this.template.name, diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts index c146d7e..0c073d0 100644 --- a/src/routes/validate/user.ts +++ b/src/routes/validate/user.ts @@ -33,7 +33,7 @@ export default class UserValidate { // validate input for user private static specialUsernames = ['admin', 'user', 'key', 'new', 'passreset']; // names a user cannot take - static input (data, param) { + static input (data, param) { // validate input, set param to 'new' to make all attributes required if (param === 'new') { return Joi.object({ name: this.user.name.required(), @@ -68,7 +68,7 @@ export default class UserValidate { // validate input for user } } - static output (data) { // validate output from database for needed properties, strip everything else + static output (data) { // validate output and strip unwanted properties, returns null if not valid data = IdValidate.stringify(data); const {value, error} = Joi.object({ _id: IdValidate.get(), diff --git a/src/test/helper.ts b/src/test/helper.ts index b7ff49f..3983959 100644 --- a/src/test/helper.ts +++ b/src/test/helper.ts @@ -4,13 +4,13 @@ import db from "../db"; export default class TestHelper { - public static auth = { + public static auth = { // test user credentials admin: {pass: 'Abc123!#', key: '000000000000000000001003'}, janedoe: {pass: 'Xyz890*)', key: '000000000000000000001002'}, user: {pass: 'Xyz890*)', key: '000000000000000000001001'}, johnnydoe: {pass: 'Xyz890*)', key: '000000000000000000001004'} } - public static res = { + public static res = { // default responses 400: {status: 'Bad request'}, 401: {status: 'Unauthorized'}, 403: {status: 'Forbidden'}, @@ -40,10 +40,10 @@ export default class TestHelper { static request (server, done, options) { // options in form: {method, url, auth: {key/basic: 'name' or 'key'/{name, pass}}, httpStatus, req, res} let st = supertest(server); - if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('key')) { + if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('key')) { // resolve API key options.url += '?key=' + (this.auth.hasOwnProperty(options.auth.key)? this.auth[options.auth.key].key : options.auth.key); } - switch (options.method) { + switch (options.method) { // http method case 'get': st = st.get(options.url) break; @@ -57,10 +57,10 @@ export default class TestHelper { st = st.delete(options.url) break; } - if (options.hasOwnProperty('req')) { + if (options.hasOwnProperty('req')) { // request body st = st.send(options.req); } - if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('basic')) { + if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('basic')) { // resolve basic auth if (this.auth.hasOwnProperty(options.auth.basic)) { st = st.auth(options.auth.basic, this.auth[options.auth.basic].pass) } @@ -70,21 +70,21 @@ export default class TestHelper { } st = st.expect('Content-type', /json/) .expect(options.httpStatus); - if (options.hasOwnProperty('res')) { + if (options.hasOwnProperty('res')) { // evaluate result return st.end((err, res) => { if (err) return done (err); should(res.body).be.eql(options.res); done(); }); } - else if (this.res.hasOwnProperty(options.httpStatus) && options.default !== false) { + else if (this.res.hasOwnProperty(options.httpStatus) && options.default !== false) { // evaluate default results return st.end((err, res) => { if (err) return done (err); should(res.body).be.eql(this.res[options.httpStatus]); done(); }); } - else { + else { // return object to do .end() manually return st; } } diff --git a/src/test/loadDev.ts b/src/test/loadDev.ts index 690044d..15a6868 100644 --- a/src/test/loadDev.ts +++ b/src/test/loadDev.ts @@ -1,5 +1,7 @@ import db from '../db'; +// script to load test db into dev db for a clean start + db.connect('dev', () => { console.info('dropping data...'); db.drop(() => { // reset database