diff --git a/api/material.yaml b/api/material.yaml index d184a3f..967071c 100644 --- a/api/material.yaml +++ b/api/material.yaml @@ -48,7 +48,7 @@ get: summary: get material details description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: status handling (accessible (only for maintain/admin))? # TODO after decision + x-doc: deleted samples are available only for maintain/admin tags: - /material responses: @@ -67,7 +67,7 @@ put: summary: change material description: 'Auth: basic, levels: write, maintain, dev, admin' - x-doc: status is reset to 0 on any changes + x-doc: status is reset to 0 on any changes, deleted samples cannot be changed tags: - /material security: @@ -117,6 +117,29 @@ 500: $ref: 'api.yaml#/components/responses/500' +/material/restore/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: restore material + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 0 + tags: + - /material + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + /material/new: post: summary: add material diff --git a/api/measurement.yaml b/api/measurement.yaml index 298b04e..3068d97 100644 --- a/api/measurement.yaml +++ b/api/measurement.yaml @@ -4,7 +4,7 @@ get: summary: measurement values by id description: 'Auth: all, levels: read, write, maintain, dev, admin' - x-doc: status handling (accessible (only for maintain/admin))? # TODO after decision + x-doc: deleted samples are available only for maintain/admin tags: - /measurement responses: @@ -25,7 +25,7 @@ put: summary: change measurement description: 'Auth: basic, levels: write, maintain, dev, admin' - x-doc: status is reset to 0 on any changes + x-doc: status is reset to 0 on any changes, deleted measurements cannot be edited tags: - /measurement security: @@ -77,6 +77,29 @@ 500: $ref: 'api.yaml#/components/responses/500' +/measurement/restore/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: restore measurement + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 0 + tags: + - /measurement + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + /measurement/new: post: summary: add measurement diff --git a/api/sample.yaml b/api/sample.yaml index 9e830ff..67f25ac 100644 --- a/api/sample.yaml +++ b/api/sample.yaml @@ -69,7 +69,7 @@ put: summary: change sample description: 'Auth: basic, levels: write, maintain, dev, admin
Only maintain and admin are allowed to edit samples created by another user' - x-doc: status is reset to 0 on any changes + x-doc: status is reset to 0 on any changes, deleted samples cannot be changed tags: - /sample security: @@ -119,6 +119,29 @@ 500: $ref: 'api.yaml#/components/responses/500' +/sample/restore/{id}: + parameters: + - $ref: 'api.yaml#/components/parameters/Id' + put: + summary: restore sample + description: 'Auth: basic, levels: maintain, admin' + x-doc: status is set to 0 + tags: + - /sample + security: + - BasicAuth: [] + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + /sample/new: post: summary: add sample diff --git a/src/index.ts b/src/index.ts index 0de6ff4..7dda199 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import db from './db'; // TODO: coverage // TODO: think about the display of deleted/new samples and validation in data and UI // TODO: improve error coverage +// TODO: guess properties from material name in UI // tell if server is running in debug or production environment console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT ====='); diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index ae8d305..56f094e 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -202,6 +202,23 @@ describe('/material', () => { res: {_id: '100000000000000000000007', name: 'Ultramid A4H', supplier: 'BASF', group: 'PA66', mineral: 0, glass_fiber: 0, carbon_fiber: 0, numbers: [{color: 'black', number: ''}]} }); }); + it('returns a deleted material for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000008', + auth: {basic: 'admin'}, + httpStatus: 200, + res: {_id: '100000000000000000000008', name: 'Latamid 66 H 2 G 30', supplier: 'LATI', group: 'PA66', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'blue', number: '5513943509'}]} + }); + }); + it('returns 403 for a write user when requesting a deleted material', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/material/100000000000000000000008', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'get', @@ -361,6 +378,15 @@ describe('/material', () => { req: {}, }); }); + it('rejects editing a deleted material', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000008', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', @@ -468,6 +494,61 @@ describe('/material', () => { }); }); + describe('PUT /material/restore/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + MaterialModel.findById('100000000000000000000008').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.new); + done(); + }); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/000000000000000000000008', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + httpStatus: 401, + req: {} + }); + }); + }); + describe('POST /material/new', () => { it('returns the right material', done => { TestHelper.request(server, done, { diff --git a/src/routes/material.ts b/src/routes/material.ts index 4a1adb8..1711eb5 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -34,14 +34,14 @@ router.get('/materials/:group(new|deleted)', (req, res, next) => { router.get('/material/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - MaterialModel.findById(req.params.id).lean().exec((err, data) => { + MaterialModel.findById(req.params.id).lean().exec((err, data: any) => { if (err) return next(err); - if (data) { - res.json(MaterialValidate.output(data)); - } - else { - res.status(404).json({status: 'Not found'}); + + if (!data) { + return res.status(404).json({status: 'Not found'}); } + if (data.status === globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted materials only available for maintain/admin + res.json(MaterialValidate.output(data)); }); }); @@ -55,6 +55,9 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { if (!materialData) { return res.status(404).json({status: 'Not found'}); } + if (materialData.status === globals.status.deleted) { + return res.status(403).json({status: 'Forbidden'}); + } if (material.hasOwnProperty('name') && material.name !== materialData.name) { if (!await nameCheck(material, res, next)) return; } @@ -92,6 +95,19 @@ router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { }); }); +router.put('/material/restore/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + MaterialModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).lean().exec((err, data) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + res.json({status: 'OK'}); + }); +}); + router.post('/material/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index 5af91a3..c27bf63 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -3,8 +3,6 @@ import MeasurementModel from '../models/measurement'; import TestHelper from "../test/helper"; import globals from '../globals'; -// TODO: restore measurements for m/a - describe('/measurement', () => { let server; @@ -255,6 +253,15 @@ describe('/measurement', () => { httpStatus: 404 }); }); + it('rejects editing a deleted measurement', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 403, + req: {} + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', @@ -358,6 +365,61 @@ describe('/measurement', () => { }); }); + describe('PUT /measurement/restore/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + MeasurementModel.findById('800000000000000000000004').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.new); + done(); + }); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/000000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + httpStatus: 401, + req: {} + }); + }); + }); + describe('POST /measurement/new', () => { it('returns the right measurement', done => { TestHelper.request(server, done, { diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index 0d0f0f6..e7f6271 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -38,6 +38,9 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { if (!data) { return res.status(404).json({status: 'Not found'}); } + if (data.status === globals.status.deleted) { + return res.status(403).json({status: 'Forbidden'}); + } // add properties needed for sampleIdCheck measurement.measurement_template = data.measurement_template; @@ -75,6 +78,19 @@ router.delete('/measurement/' + IdValidate.parameter(), (req, res, next) => { }); }); +router.put('/measurement/restore/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + MeasurementModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).lean().exec((err, data) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + res.json({status: 'OK'}); + }); +}); + router.post('/measurement/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index cfeeb7c..b90a722 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -2,15 +2,16 @@ import should from 'should/as-function'; import SampleModel from '../models/sample'; import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; +import MeasurementModel from '../models/measurement'; import TestHelper from "../test/helper"; import globals from '../globals'; +import mongoose from 'mongoose'; // TODO: generate output for ML in format DPT -> data, implement filtering, field selection +// TODO: generate csv // TODO: filter by not completely filled/no measurements // TODO: write script for data import -// TODO: delete everything (measurements, condition) with sample // TODO: allow adding sample numbers for existing samples - // TODO: Do not allow validation or measurement entry without condition @@ -187,7 +188,6 @@ describe('/sample', () => { res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, user: 'admin'} }); }); - it('works with an API key', done => { TestHelper.request(server, done, { method: 'get', @@ -197,7 +197,6 @@ describe('/sample', () => { res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, user: 'admin'} }); }); - it('returns a deleted sample for a maintain/admin user', done => { TestHelper.request(server, done, { method: 'get', @@ -207,7 +206,6 @@ describe('/sample', () => { res: {_id: '400000000000000000000005', number: 'Rng33', type: 'granulate', color: 'black', batch: '1653000308', condition: {condition_template: '200000000000000000000003'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {}, user: 'admin'} }); }); - it('returns 403 for a write user when requesting a deleted sample', done => { TestHelper.request(server, done, { method: 'get', @@ -216,7 +214,6 @@ describe('/sample', () => { httpStatus: 403 }); }); - it('returns 404 for an unknown sample', done => { TestHelper.request(server, done, { method: 'get', @@ -225,7 +222,6 @@ describe('/sample', () => { httpStatus: 404 }); }); - it('rejects an invalid id', done => { TestHelper.request(server, done, { method: 'get', @@ -234,7 +230,6 @@ describe('/sample', () => { httpStatus: 404 }); }); - it('rejects unauthorized requests', done => { TestHelper.request(server, done, { method: 'get', @@ -589,6 +584,15 @@ describe('/sample', () => { res: {status: 'Condition template not available'} }); }); + it('rejects editing a deleted sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 403, + req: {} + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', @@ -749,6 +753,24 @@ describe('/sample', () => { }); }); }); + it('deletes associated measurements', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + MeasurementModel.find({sample_id: mongoose.Types.ObjectId('400000000000000000000001')}).lean().exec((err, data: any) => { + if (err) return done(err); + should(data).matchEach(sample => { + should(sample).have.property('status', -1); + }); + done(); + }); + }); + }); it('rejects deleting samples of other users for write users', done => { TestHelper.request(server, done, { method: 'delete', @@ -765,7 +787,6 @@ describe('/sample', () => { httpStatus: 404 }); }); - it('rejects deleting a sample referenced by conditions'); // TODO after decision it('rejects requests from a read user', done => { TestHelper.request(server, done, { method: 'delete', @@ -799,6 +820,61 @@ describe('/sample', () => { }); }); + describe('PUT /sample/restore/{id}', () => { + it('sets the status', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {} + }).end((err, res) => { + if (err) return done (err); + should(res.body).be.eql({status: 'OK'}); + SampleModel.findById('400000000000000000000005').lean().exec((err, data: any) => { + if (err) return done(err); + should(data).have.property('status',globals.status.new); + done(); + }); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + auth: {key: 'admin'}, + httpStatus: 401, + req: {} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {} + }); + }); + it('returns 404 for an unknown sample', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/000000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 404, + req: {} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + httpStatus: 401, + req: {} + }); + }); + }); + describe('POST /sample/new', () => { it('returns the right sample', done => { TestHelper.request(server, done, { diff --git a/src/routes/sample.ts b/src/routes/sample.ts index ed1afb3..e8ed1f7 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -10,7 +10,7 @@ import MaterialModel from '../models/material'; import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; import IdValidate from './validate/id'; -import mongoose from "mongoose"; +import mongoose from 'mongoose'; import ConditionTemplateModel from '../models/condition_template'; import ParametersValidate from './validate/parameters'; import globals from '../globals'; @@ -43,7 +43,7 @@ router.get('/sample/' + IdValidate.parameter(), (req, res, next) => { if (err) return next(err); if (sampleData) { - if (sampleData.status ===globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted samples only available for maintain/admin + if (sampleData.status === globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted samples only available for maintain/admin sampleData.material = sampleData.material_id; // map data to right keys sampleData.user = sampleData.user_id.name; sampleData.notes = sampleData.note_id ? sampleData.note_id : {}; @@ -69,6 +69,9 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (!sampleData) { return res.status(404).json({status: 'Not found'}); } + if (sampleData.status === globals.status.deleted) { + return res.status(403).json({status: 'Forbidden'}); + } // only maintain and admin are allowed to edit other user's data if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; @@ -138,22 +141,41 @@ router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { await SampleModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).lean().exec(err => { // set sample status if (err) return next(err); - if (sampleData.note_id !== null) { // handle notes - NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { // find notes to update note_fields - if (err) return next(err); - if (data.hasOwnProperty('custom_fields')) { // update note_fields - customFieldsChange(Object.keys(data.custom_fields), -1); - } + + // set status of associated measurements also to deleted + MeasurementModel.update({sample_id: mongoose.Types.ObjectId(req.params.id)}, {status: -1}).lean().exec(err => { + if (err) return next(err); + + if (sampleData.note_id !== null) { // handle notes + NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { // find notes to update note_fields + if (err) return next(err); + if (data.hasOwnProperty('custom_fields')) { // update note_fields + customFieldsChange(Object.keys(data.custom_fields), -1); + } + res.json({status: 'OK'}); + }); + } + else { res.json({status: 'OK'}); - }); - } - else { - res.json({status: 'OK'}); - } + } + }); }); }); }); +router.put('/sample/restore/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).lean().exec((err, data) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + res.json({status: 'OK'}); + }); +}); + router.post('/sample/new', async (req, res, next) => { if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; diff --git a/src/test/db.json b/src/test/db.json index 372b09a..de4070f 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -268,6 +268,23 @@ ], "status": 0, "__v": 0 + }, + { + "_id": {"$oid":"100000000000000000000008"}, + "name": "Latamid 66 H 2 G 30", + "supplier": "LATI", + "group": "PA66", + "mineral": 0, + "glass_fiber": 30, + "carbon_fiber": 0, + "numbers": [ + { + "color": "blue", + "number": "5513943509" + } + ], + "status": -1, + "__v": 0 } ], "measurements": [