diff --git a/.idea/dictionaries/VLE2FE.xml b/.idea/dictionaries/VLE2FE.xml index 1dd7309..5337928 100644 --- a/.idea/dictionaries/VLE2FE.xml +++ b/.idea/dictionaries/VLE2FE.xml @@ -5,6 +5,7 @@ cfenv dfopdb janedoe + pagesize testcomment diff --git a/api/api.yaml b/api/api.yaml index 9090378..d281206 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -62,7 +62,7 @@ tags: paths: allOf: - - $ref: 'others.yaml' + - $ref: 'root.yaml' - $ref: 'sample.yaml' - $ref: 'material.yaml' - $ref: 'measurement.yaml' diff --git a/api/others.yaml b/api/others.yaml deleted file mode 100644 index a953bf8..0000000 --- a/api/others.yaml +++ /dev/null @@ -1,43 +0,0 @@ -/: - get: - summary: Root method - description: 'Auth: none' - tags: - - / - security: [] - responses: - 200: - description: Server is working - content: - application/json: - schema: - properties: - status: - type: string - example: 'API server up and running!' - 500: - $ref: 'api.yaml#/components/responses/500' - -/authorized: - get: - summary: Checks authorization - description: 'Auth: all, levels: read, write, maintain, dev, admin' - tags: - - / - responses: - 200: - description: Authorized - content: - application/json: - schema: - properties: - status: - type: string - example: 'Authorization successful' - method: - type: string - example: 'basic' - 401: - $ref: 'api.yaml#/components/responses/401' - 500: - $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/api/root.yaml b/api/root.yaml new file mode 100644 index 0000000..3070412 --- /dev/null +++ b/api/root.yaml @@ -0,0 +1,102 @@ +/: + get: + summary: Root method + description: 'Auth: none' + tags: + - / + security: [] + responses: + 200: + description: Server is working + content: + application/json: + schema: + properties: + status: + type: string + example: 'API server up and running!' + 500: + $ref: 'api.yaml#/components/responses/500' + +/authorized: + get: + summary: Checks authorization + description: 'Auth: all, levels: read, write, maintain, dev, admin' + tags: + - / + responses: + 200: + description: Authorized + content: + application/json: + schema: + properties: + status: + type: string + example: 'Authorization successful' + method: + type: string + example: 'basic' + 401: + $ref: 'api.yaml#/components/responses/401' + 500: + $ref: 'api.yaml#/components/responses/500' + +/changelog/{timestamp}/{page}/{pagesize}: + parameters: + - name: timestamp + in: path + required: true + schema: + type: string + example: 1970-01-01T00:00:00.000Z + - name: page + in: path + required: true + schema: + type: number + example: 3 + - name: pagesize + in: path + required: true + schema: + type: number + example: 30 + get: + summary: get changelog + description: 'Auth: basic, levels: maintain, admin
Displays all logs older than timestamp, sorted by date descending, page defaults to 0, pagesize defaults to 25
Avoid using high page numbers for older logs, better use an older timestamp' + tags: + - / + responses: + 200: + description: Changelog + content: + application/json: + schema: + properties: + date: + type: string + example: 1970-01-01T00:00:00.000Z + action: + type: string + example: PUT /sample/400000000000000000000001 + collection: + type: string + example: samples + conditions: + type: object + example: + _id: '400000000000000000000001' + data: + type: object + example: + type: part + status: 0 + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' \ No newline at end of file diff --git a/src/db.ts b/src/db.ts index fb5d424..60dadf9 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,5 +1,7 @@ import mongoose from 'mongoose'; import cfenv from 'cfenv'; +import _ from 'lodash'; +import ChangelogModel from './models/changelog'; // mongoose.set('debug', true); // enable mongoose debug @@ -112,6 +114,27 @@ export default class db { }); } + // changelog entry + static log(req, thisOrCollection, conditions = null, data = null) { // expects (req, this (from query helper)) or (req, collection, conditions, data) + if (! (conditions || data)) { // (req, this) + data = thisOrCollection._update ? _.cloneDeep(thisOrCollection._update) : {}; // replace undefined with {} + Object.keys(data).forEach(key => { + if (key[0] === '$') { + data[key.substr(1)] = data[key]; + delete data[key]; + } + }); + new ChangelogModel({action: req.method + ' ' + req.url, collectionName: thisOrCollection._collection.collectionName, conditions: thisOrCollection._conditions, data: data, user_id: req.authDetails.id ? req.authDetails.id : null}).save(err => { + if (err) console.error(err); + }); + } + else { // (req, collection, conditions, data) + new ChangelogModel({action: req.method + ' ' + req.url, collectionName: thisOrCollection, conditions: conditions, data: data, user_id: req.authDetails.id ? req.authDetails.id : null}).save(err => { + if (err) console.error(err); + }); + } + } + private static oidResolve (object: any) { // resolve $oid fields to actual ObjectIds recursively Object.keys(object).forEach(key => { if (object[key] !== null && object[key].hasOwnProperty('$oid')) { // found oid, replace @@ -123,4 +146,4 @@ export default class db { }); return object; } -}; \ No newline at end of file +}; diff --git a/src/index.ts b/src/index.ts index 3776c34..4cf4f45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,6 @@ import mongoSanitize from 'mongo-sanitize'; import api from './api'; import db from './db'; -// TODO: changelog // TODO: check executing index.js/move everything needed into dist // TODO: validation: VZ, Humidity: min/max value, DPT: filename // TODO: add multiple samples at once (only GUI) diff --git a/src/models/changelog.ts b/src/models/changelog.ts new file mode 100644 index 0000000..75600c4 --- /dev/null +++ b/src/models/changelog.ts @@ -0,0 +1,11 @@ +import mongoose from 'mongoose'; + +const ChangelogSchema = new mongoose.Schema({ + action: String, + collectionName: String, + conditions: Object, + data: Object, + user_id: mongoose.Schema.Types.ObjectId +}, {minimize: false}); + +export default mongoose.model>('changelog', ChangelogSchema); \ No newline at end of file diff --git a/src/models/condition_template.ts b/src/models/condition_template.ts index 62bf621..ca61da2 100644 --- a/src/models/condition_template.ts +++ b/src/models/condition_template.ts @@ -1,13 +1,20 @@ import mongoose from 'mongoose'; +import db from '../db'; const ConditionTemplateSchema = new mongoose.Schema({ first_id: mongoose.Schema.Types.ObjectId, name: String, version: Number, - parameters: [{ + parameters: [new mongoose.Schema({ name: String, range: mongoose.Schema.Types.Mixed - }] + } ,{ _id : false })] }, {minimize: false}); // to allow empty objects -export default mongoose.model('condition_template', ConditionTemplateSchema); \ No newline at end of file +// changelog query helper +ConditionTemplateSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('condition_template', ConditionTemplateSchema); \ No newline at end of file diff --git a/src/models/material.ts b/src/models/material.ts index a183020..bcebb83 100644 --- a/src/models/material.ts +++ b/src/models/material.ts @@ -1,14 +1,15 @@ import mongoose from 'mongoose'; import MaterialSupplierModel from '../models/material_suppliers'; import MaterialGroupsModel from '../models/material_groups'; +import db from '../db'; const MaterialSchema = new mongoose.Schema({ name: {type: String, index: {unique: true}}, supplier_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialSupplierModel}, group_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialGroupsModel}, - mineral: String, - glass_fiber: String, - carbon_fiber: String, + mineral: Number, + glass_fiber: Number, + carbon_fiber: Number, numbers: [{ color: String, number: String @@ -16,4 +17,10 @@ const MaterialSchema = new mongoose.Schema({ status: Number }, {minimize: false}); -export default mongoose.model('material', MaterialSchema); \ No newline at end of file +// changelog query helper +MaterialSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('material', MaterialSchema); \ No newline at end of file diff --git a/src/models/material_groups.ts b/src/models/material_groups.ts index e9c9861..00be706 100644 --- a/src/models/material_groups.ts +++ b/src/models/material_groups.ts @@ -1,7 +1,14 @@ import mongoose from 'mongoose'; +import db from '../db'; const MaterialGroupsSchema = new mongoose.Schema({ name: {type: String, index: {unique: true}} }); -export default mongoose.model('material_groups', MaterialGroupsSchema); \ No newline at end of file +// changelog query helper +MaterialGroupsSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('material_groups', MaterialGroupsSchema); \ No newline at end of file diff --git a/src/models/material_suppliers.ts b/src/models/material_suppliers.ts index 573d397..5c47e3b 100644 --- a/src/models/material_suppliers.ts +++ b/src/models/material_suppliers.ts @@ -1,7 +1,14 @@ import mongoose from 'mongoose'; +import db from '../db'; const MaterialSuppliersSchema = new mongoose.Schema({ name: {type: String, index: {unique: true}} }); -export default mongoose.model('material_suppliers', MaterialSuppliersSchema); \ No newline at end of file +// changelog query helper +MaterialSuppliersSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('material_suppliers', MaterialSuppliersSchema); \ No newline at end of file diff --git a/src/models/measurement.ts b/src/models/measurement.ts index d003ea5..1136e6b 100644 --- a/src/models/measurement.ts +++ b/src/models/measurement.ts @@ -1,6 +1,7 @@ import mongoose from 'mongoose'; import SampleModel from './sample'; import MeasurementTemplateModel from './measurement_template'; +import db from '../db'; @@ -11,4 +12,10 @@ const MeasurementSchema = new mongoose.Schema({ status: Number }, {minimize: false}); -export default mongoose.model('measurement', MeasurementSchema); \ No newline at end of file +// changelog query helper +MeasurementSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('measurement', MeasurementSchema); \ No newline at end of file diff --git a/src/models/measurement_template.ts b/src/models/measurement_template.ts index af14fee..b34e847 100644 --- a/src/models/measurement_template.ts +++ b/src/models/measurement_template.ts @@ -1,13 +1,20 @@ import mongoose from 'mongoose'; +import db from '../db'; const MeasurementTemplateSchema = new mongoose.Schema({ first_id: mongoose.Schema.Types.ObjectId, name: String, version: Number, - parameters: [{ + parameters: [new mongoose.Schema({ name: String, range: mongoose.Schema.Types.Mixed - }] + } ,{ _id : false })] }, {minimize: false}); // to allow empty objects -export default mongoose.model('measurement_template', MeasurementTemplateSchema); \ No newline at end of file +// changelog query helper +MeasurementTemplateSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('measurement_template', MeasurementTemplateSchema); \ No newline at end of file diff --git a/src/models/note.ts b/src/models/note.ts index cd0847b..5d02502 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -1,4 +1,5 @@ import mongoose from 'mongoose'; +import db from '../db'; const NoteSchema = new mongoose.Schema({ comment: String, @@ -9,4 +10,10 @@ const NoteSchema = new mongoose.Schema({ custom_fields: mongoose.Schema.Types.Mixed }); -export default mongoose.model('note', NoteSchema); \ No newline at end of file +// changelog query helper +NoteSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('note', NoteSchema); \ No newline at end of file diff --git a/src/models/note_field.ts b/src/models/note_field.ts index 86158e3..733ba02 100644 --- a/src/models/note_field.ts +++ b/src/models/note_field.ts @@ -1,8 +1,15 @@ import mongoose from 'mongoose'; +import db from '../db'; const NoteFieldSchema = new mongoose.Schema({ name: {type: String, index: {unique: true}}, qty: Number }); -export default mongoose.model('note_field', NoteFieldSchema); \ No newline at end of file +// changelog query helper +NoteFieldSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('note_field', NoteFieldSchema); \ No newline at end of file diff --git a/src/models/sample.ts b/src/models/sample.ts index 1338728..0e457d8 100644 --- a/src/models/sample.ts +++ b/src/models/sample.ts @@ -3,6 +3,7 @@ import mongoose from 'mongoose'; import MaterialModel from './material'; import NoteModel from './note'; import UserModel from './user'; +import db from '../db'; const SampleSchema = new mongoose.Schema({ number: {type: String, index: {unique: true}}, @@ -16,4 +17,10 @@ const SampleSchema = new mongoose.Schema({ status: Number }, {minimize: false}); -export default mongoose.model('sample', SampleSchema); \ No newline at end of file +// changelog query helper +SampleSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('sample', SampleSchema); \ No newline at end of file diff --git a/src/models/user.ts b/src/models/user.ts index 50178a6..1e50d0c 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -1,4 +1,5 @@ import mongoose from 'mongoose'; +import db from '../db'; const UserSchema = new mongoose.Schema({ name: {type: String, index: {unique: true}}, @@ -10,4 +11,10 @@ const UserSchema = new mongoose.Schema({ device_name: String }); -export default mongoose.model('user', UserSchema); \ No newline at end of file +// changelog query helper +UserSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('user', UserSchema); \ No newline at end of file diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 9645d1b..e91e87e 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -307,7 +307,6 @@ describe('/material', () => { auth: {basic: 'janedoe'}, httpStatus: 200, req: {name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]} - , }).end((err, res) => { if (err) return done(err); should(res.body).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]}); @@ -317,7 +316,7 @@ describe('/material', () => { data.group_id = data.group_id.toString(); data.supplier_id = data.supplier_id.toString(); data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}}); - should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier_id: '110000000000000000000002', group_id: '900000000000000000000002', mineral: '0', glass_fiber: '35', carbon_fiber: '0', numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: 0, __v: 0}); + should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier_id: '110000000000000000000002', group_id: '900000000000000000000002', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: 0, __v: 0}); MaterialGroupModel.find({name: 'PA6/6T'}).lean().exec((err, data) => { if (err) return done(err); should(data).have.lengthOf(1); @@ -332,6 +331,24 @@ describe('/material', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/100000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]}, + log: { + collection: 'materials', + dataAdd: { + group_id: '900000000000000000000002', + supplier_id: '110000000000000000000002', + status: 0 + }, + dataIgn: ['supplier', 'group'] + } + }); + }); it('accepts a color without number', done => { TestHelper.request(server, done, { method: 'put', @@ -469,6 +486,18 @@ describe('/material', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/material/100000000000000000000002', + auth: {basic: 'janedoe'}, + httpStatus: 200, + log: { + collection: 'materials', + dataAdd: { status: -1} + } + }); + }); it('rejects deleting a material referenced by samples', done => { TestHelper.request(server, done, { method: 'delete', @@ -537,6 +566,21 @@ describe('/material', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/restore/100000000000000000000008', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'materials', + dataAdd: { + status: 0 + } + } + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', @@ -592,6 +636,21 @@ describe('/material', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/material/validate/100000000000000000000007', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'materials', + dataAdd: { + status: 10 + } + } + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', @@ -669,9 +728,9 @@ describe('/material', () => { should(materialData).have.lengthOf(1); should(materialData[0]).have.only.keys('_id', 'name', 'supplier_id', 'group_id', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v'); should(materialData[0]).have.property('name', 'Crastin CE 2510'); - should(materialData[0]).have.property('mineral', '0'); - should(materialData[0]).have.property('glass_fiber', '30'); - should(materialData[0]).have.property('carbon_fiber', '0'); + should(materialData[0]).have.property('mineral', 0); + should(materialData[0]).have.property('glass_fiber', 30); + should(materialData[0]).have.property('carbon_fiber', 0); should(materialData[0]).have.property('status',globals.status.new); should(materialData[0].numbers).have.lengthOf(0); MaterialGroupModel.findById(materialData[0].group_id).lean().exec((err, data) => { @@ -686,6 +745,20 @@ describe('/material', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/material/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []}, + log: { + collection: 'materials', + dataAdd: {status: 0}, + dataIgn: ['group_id', 'supplier_id', 'group', 'supplier'] + } + }); + }); it('accepts a color without number', done => { TestHelper.request(server, done, { method: 'post', @@ -714,9 +787,9 @@ describe('/material', () => { should(data[0]).have.only.keys('_id', 'name', 'supplier_id', 'group_id', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v'); should(data[0]).have.property('_id'); should(data[0]).have.property('name', 'Crastin CE 2510'); - should(data[0]).have.property('mineral', '0'); - should(data[0]).have.property('glass_fiber', '30'); - should(data[0]).have.property('carbon_fiber', '0'); + should(data[0]).have.property('mineral', 0); + should(data[0]).have.property('glass_fiber', 30); + should(data[0]).have.property('carbon_fiber', 0); should(data[0]).have.property('status',globals.status.new); should(_.omit(data[0].numbers[0], '_id')).be.eql({color: 'black', number: ''}); done(); diff --git a/src/routes/material.ts b/src/routes/material.ts index 4be1137..8373c9d 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -10,6 +10,7 @@ import IdValidate from './validate/id'; import res400 from './validate/res400'; import mongoose from 'mongoose'; import globals from '../globals'; +import db from '../db'; @@ -67,11 +68,11 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { if (!await nameCheck(material, res, next)) return; } if (material.hasOwnProperty('group')) { - material = await groupResolve(material, next); + material = await groupResolve(material, req, next); if (!material) return; } if (material.hasOwnProperty('supplier')) { - material = await supplierResolve(material, next); + material = await supplierResolve(material, req, next); if (!material) return; } @@ -80,7 +81,7 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => { material.status = globals.status.new; // set status to new } - await MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).populate('group_id').populate('supplier_id').lean().exec((err, data) => { + await MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).log(req).populate('group_id').populate('supplier_id').lean().exec((err, data) => { if (err) return next(err); res.json(MaterialValidate.output(data)); }); @@ -96,7 +97,7 @@ router.delete('/material/' + IdValidate.parameter(), (req, res, next) => { if (data.length) { return res.status(400).json({status: 'Material still in use'}); } - MaterialModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).populate('group_id').populate('supplier_id').lean().exec((err, data) => { + MaterialModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).log(req).populate('group_id').populate('supplier_id').lean().exec((err, data) => { if (err) return next(err); if (data) { res.json({status: 'OK'}); @@ -127,15 +128,16 @@ router.post('/material/new', async (req, res, next) => { if (error) return res400(error, res); if (!await nameCheck(material, res, next)) return; - material = await groupResolve(material, next); + material = await groupResolve(material, req, next); if (!material) return; - material = await supplierResolve(material, next); + material = await supplierResolve(material, req, next); if (!material) return; material.status = globals.status.new; // set status to new await new MaterialModel(material).save(async (err, data) => { if (err) return next(err); + db.log(req, 'materials', {_id: data._id}, data.toObject()); await data.populate('group_id').populate('supplier_id').execPopulate().catch(err => next(err)); if (data instanceof Error) return; res.json(MaterialValidate.output(data.toObject())); @@ -176,16 +178,16 @@ async function nameCheck (material, res, next) { // check if name was already t return true; } -async function groupResolve (material, next) { - const groupData = await MaterialGroupModel.findOneAndUpdate({name: material.group}, {name: material.group}, {upsert: true, new: true}).lean().exec().catch(err => next(err)) as any; +async function groupResolve (material, req, next) { + const groupData = await MaterialGroupModel.findOneAndUpdate({name: material.group}, {name: material.group}, {upsert: true, new: true}).log(req).lean().exec().catch(err => next(err)) as any; if (groupData instanceof Error) return false; material.group_id = groupData._id; delete material.group; return material; } -async function supplierResolve (material, next) { - const supplierData = await MaterialSupplierModel.findOneAndUpdate({name: material.supplier}, {name: material.supplier}, {upsert: true, new: true}).lean().exec().catch(err => next(err)) as any; +async function supplierResolve (material, req, next) { + const supplierData = await MaterialSupplierModel.findOneAndUpdate({name: material.supplier}, {name: material.supplier}, {upsert: true, new: true}).log(req).lean().exec().catch(err => next(err)) as any; if (supplierData instanceof Error) return false; material.supplier_id = supplierData._id; delete material.supplier; @@ -193,7 +195,7 @@ async function supplierResolve (material, next) { } function setStatus (status, req, res, next) { // set measurement status - MaterialModel.findByIdAndUpdate(req.params.id, {status: status}).lean().exec((err, data) => { + MaterialModel.findByIdAndUpdate(req.params.id, {status: status}).log(req).lean().exec((err, data) => { if (err) return next(err); if (!data) { diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts index 25cc5e9..dd43520 100644 --- a/src/routes/measurement.spec.ts +++ b/src/routes/measurement.spec.ts @@ -138,6 +138,23 @@ describe('/measurement', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {values: {dpt: [[1,2],[3,4],[5,6]]}}, + log: { + collection: 'measurements', + dataAdd: { + measurement_template: '300000000000000000000001', + sample_id: '400000000000000000000001', + status: 0 + } + } + }); + }); it('allows changing only one value', done => { TestHelper.request(server, done, { method: 'put', @@ -296,7 +313,7 @@ describe('/measurement', () => { method: 'delete', url: '/measurement/800000000000000000000001', auth: {basic: 'janedoe'}, - httpStatus: 200, + httpStatus: 200 }).end((err, res) => { if (err) return done(err); should(res.body).be.eql({status: 'OK'}); @@ -307,6 +324,20 @@ describe('/measurement', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + log: { + collection: 'measurements', + dataAdd: { + status: -1 + } + } + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'delete', @@ -383,6 +414,21 @@ describe('/measurement', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/restore/800000000000000000000004', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'measurements', + dataAdd: { + status: 0 + } + } + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', @@ -438,6 +484,21 @@ describe('/measurement', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/measurement/validate/800000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'measurements', + dataAdd: { + status: 10 + } + } + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', @@ -517,6 +578,21 @@ describe('/measurement', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + log: { + collection: 'measurements', + dataAdd: { + status: 0 + } + } + }); + }); it('rejects an invalid sample id', done => { TestHelper.request(server, done, { method: 'post', diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts index a1b3a94..47af305 100644 --- a/src/routes/measurement.ts +++ b/src/routes/measurement.ts @@ -9,6 +9,7 @@ import IdValidate from './validate/id'; import res400 from './validate/res400'; import ParametersValidate from './validate/parameters'; import globals from '../globals'; +import db from '../db'; const router = express.Router(); @@ -56,7 +57,7 @@ router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => { } if (!await templateCheck(measurement, 'change', res, next)) return; - await MeasurementModel.findByIdAndUpdate(req.params.id, measurement, {new: true}).lean().exec((err, data) => { + await MeasurementModel.findByIdAndUpdate(req.params.id, measurement, {new: true}).log(req).lean().exec((err, data) => { if (err) return next(err); res.json(MeasurementValidate.output(data)); }); @@ -71,7 +72,7 @@ router.delete('/measurement/' + IdValidate.parameter(), (req, res, next) => { return res.status(404).json({status: 'Not found'}); } if (!await sampleIdCheck(data, req, res, next)) return; - await MeasurementModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).lean().exec(err => { + await MeasurementModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).log(req).lean().exec(err => { if (err) return next(err); return res.json({status: 'OK'}); }); @@ -103,6 +104,7 @@ router.post('/measurement/new', async (req, res, next) => { measurement.status = 0; await new MeasurementModel(measurement).save((err, data) => { if (err) return next(err); + db.log(req, 'measurements', {_id: data._id}, data.toObject()); res.json(MeasurementValidate.output(data.toObject())); }); }); @@ -156,7 +158,7 @@ async function templateCheck (measurement, param, res, next) { // validate meas } function setStatus (status, req, res, next) { // set measurement status - MeasurementModel.findByIdAndUpdate(req.params.id, {status: status}).lean().exec((err, data) => { + MeasurementModel.findByIdAndUpdate(req.params.id, {status: status}).log(req).lean().exec((err, data) => { if (err) return next(err); if (!data) { diff --git a/src/routes/root.spec.ts b/src/routes/root.spec.ts index 569af8b..68531a5 100644 --- a/src/routes/root.spec.ts +++ b/src/routes/root.spec.ts @@ -1,4 +1,5 @@ import TestHelper from "../test/helper"; +import should from 'should/as-function'; import db from '../db'; @@ -20,6 +21,121 @@ describe('/', () => { }); }); + describe('GET /changelog/{timestamp}/{page}/{pagesize}', () => { + it('returns the first page', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/0/2', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(2); + should(res.body[0].date).be.eql('1979-07-28T06:04:51.000Z'); + should(res.body[1].date).be.eql('1979-07-28T06:04:50.000Z'); + should(res.body).matchEach(log => { + should(log).have.only.keys('date', 'action', 'collection', 'conditions', 'data'); + should(log).have.property('action', 'PUT /sample/400000000000000000000001'); + should(log).have.property('collection', 'samples'); + should(log).have.property('conditions', {_id: '400000000000000000000001'}); + should(log).have.property('data', {type: 'part', status: 0}); + }); + done(); + }); + }); + it('returns another page', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/1/2', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(1); + should(res.body[0].date).be.eql('1979-07-28T06:04:49.000Z'); + should(res.body).matchEach(log => { + should(log).have.only.keys('date', 'action', 'collection', 'conditions', 'data'); + should(log).have.property('action', 'PUT /sample/400000000000000000000001'); + should(log).have.property('collection', 'samples'); + should(log).have.property('conditions', {_id: '400000000000000000000001'}); + should(log).have.property('data', {type: 'part', status: 0}); + done(); + }); + }); + }); + it('returns an empty array for a page with no results', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/2', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.lengthOf(0); + done(); + }); + }); + it('rejects timestamps pre unix epoch', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1879-07-28T06:04:51.000Z/10/2', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"timestamp" must be larger than or equal to "1970-01-01T00:00:00.000Z"'} + }); + }); + it('rejects invalid timestamps', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-14-28T06:04:51.000Z/10/2', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"timestamp" must be in ISO 8601 date format'} + }); + }); + it('rejects negative page numbers', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/-10/2', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"page" must be larger than or equal to 0'} + }); + }); + it('rejects negative pagesizes', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/-2', + auth: {basic: 'admin'}, + httpStatus: 400, + res: {status: 'Invalid body format', details: '"pagesize" must be larger than or equal to 0'} + }); + }); + it('rejects request from a write user', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/2', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects requests from an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/2', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/changelog/1979-07-28T06:04:51.000Z/10/2', + httpStatus: 401 + }); + }); + }); + describe('Unknown routes', () => { it('return a 404 message', done => { TestHelper.request(server, done, { diff --git a/src/routes/root.ts b/src/routes/root.ts index 2705280..946948f 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -1,5 +1,10 @@ import express from 'express'; import globals from '../globals'; +import RootValidate from './validate/root'; +import res400 from './validate/res400'; +import ChangelogModel from '../models/changelog'; +import mongoose from 'mongoose'; +import _ from 'lodash'; const router = express.Router(); @@ -12,4 +17,18 @@ router.get('/authorized', (req, res) => { res.json({status: 'Authorization successful', method: req.authDetails.method}); }); +router.get('/changelog/:timestamp/:page?/:pagesize?', (req, res, next) => { + if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; + + const {error, value: options} = RootValidate.changelogParams({timestamp: req.params.timestamp, page: req.params.page, pagesize: req.params.pagesize}); + if (error) return res400(error, res); + + const id = new mongoose.Types.ObjectId(Math.floor(new Date(options.timestamp).getTime() / 1000).toString(16) + '0000000000000000'); + ChangelogModel.find({_id: {$lte: id}}).sort({_id: -1}).skip(options.page * options.pagesize).limit(options.pagesize).lean().exec((err, data) => { + if (err) return next(err); + + res.json(_.compact(data.map(e => RootValidate.changelogOutput(e)))); // validate all and filter null values from validation errors + }); +}); + module.exports = router; diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index baa46fe..9ce2a88 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -13,7 +13,6 @@ import mongoose from 'mongoose'; // TODO: write script for data import // TODO: allow adding sample numbers for existing samples - describe('/sample', () => { let server; before(done => TestHelper.before(done)); @@ -374,6 +373,22 @@ describe('/sample', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {type: 'part', color: 'signalviolet', batch: '114531', condition: {condition_template: '200000000000000000000003'}, material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + log: { + collection: 'samples', + dataAdd: { + status: 0 + }, + dataIgn: ['notes', 'note_id'] + } + }); + }); it('adjusts the note_fields correctly', done => { TestHelper.request(server, done, { method: 'put', @@ -707,6 +722,19 @@ describe('/sample', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/sample/400000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + log: { + collection: 'samples', + skip: 1, + dataAdd: {status: -1} + } + }); + }); it('keeps the notes of the sample', done => { TestHelper.request(server, done, { method: 'delete', @@ -867,6 +895,24 @@ describe('/sample', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/restore/400000000000000000000005', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'samples', + dataAdd: { + group_id: '900000000000000000000002', + supplier_id: '110000000000000000000002', + status: 0 + }, + dataIgn: ['group_id', 'supplier_id'] + } + }); + }); it('rejects an API key', done => { TestHelper.request(server, done, { method: 'put', @@ -922,6 +968,24 @@ describe('/sample', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/sample/validate/400000000000000000000003', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {}, + log: { + collection: 'samples', + dataAdd: { + group_id: '900000000000000000000002', + supplier_id: '110000000000000000000002', + status: 10 + }, + dataIgn: ['group_id', 'supplier_id'] + } + }); + }); it('rejects validating a sample without condition', done => { TestHelper.request(server, done, { method: 'put', @@ -1038,6 +1102,24 @@ describe('/sample', () => { }) }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/sample/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}, + log: { + collection: 'samples', + dataAdd: { + number: 'Rng37', + user_id: '000000000000000000000002', + status: 0 + }, + dataIgn: ['notes', 'note_id'] + } + }); + }); it('stores the custom fields', done => { TestHelper.request(server, done, { method: 'post', diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 15d2a62..3966c9b 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -14,6 +14,7 @@ import mongoose from 'mongoose'; import ConditionTemplateModel from '../models/condition_template'; import ParametersValidate from './validate/parameters'; import globals from '../globals'; +import db from '../db'; const router = express.Router(); @@ -101,9 +102,9 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { newNotes = !_.isEqual(_.pick(IdValidate.stringify(data), _.keys(sample.notes)), sample.notes); // check if notes were changed if (newNotes) { if (data.hasOwnProperty('custom_fields')) { // update note_fields - customFieldsChange(Object.keys(data.custom_fields), -1); + customFieldsChange(Object.keys(data.custom_fields), -1, req); } - await NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec(err => { // delete old notes + await NoteModel.findByIdAndDelete(sampleData.note_id).log(req).lean().exec(err => { // delete old notes if (err) return console.error(err); }); } @@ -112,9 +113,10 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { if (_.keys(sample.notes).length > 0 && newNotes) { // save new notes if (!await sampleRefCheck(sample, res, next)) return; if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields - customFieldsChange(Object.keys(sample.notes.custom_fields), 1); + customFieldsChange(Object.keys(sample.notes.custom_fields), 1, req); } let data = await new NoteModel(sample.notes).save().catch(err => { return next(err)}); // save new notes + db.log(req, 'notes', {_id: data._id}, data.toObject()); delete sample.notes; sample.note_id = data._id; } @@ -125,7 +127,7 @@ router.put('/sample/' + IdValidate.parameter(), (req, res, next) => { sample.status = globals.status.new; } - await SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).lean().exec((err, data: any) => { + await SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).log(req).lean().exec((err, data: any) => { if (err) return next(err); res.json(SampleValidate.output(data)); }); @@ -145,18 +147,18 @@ router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { // only maintain and admin are allowed to edit other user's data if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return; - await SampleModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).lean().exec(err => { // set sample status + await SampleModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).log(req).lean().exec(err => { // set sample status if (err) return next(err); // set status of associated measurements also to deleted - MeasurementModel.updateMany({sample_id: mongoose.Types.ObjectId(req.params.id)}, {status: -1}).lean().exec(err => { + MeasurementModel.updateMany({sample_id: mongoose.Types.ObjectId(req.params.id)}, {status: -1}).log(req).lean().exec(err => { if (err) return next(err); if (sampleData.note_id !== null) { // handle notes NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { // find notes to update note_fields if (err) return next(err); if (data.hasOwnProperty('custom_fields')) { // update note_fields - customFieldsChange(Object.keys(data.custom_fields), -1); + customFieldsChange(Object.keys(data.custom_fields), -1, req); } res.json({status: 'OK'}); }); @@ -172,7 +174,7 @@ router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => { router.put('/sample/restore/' + IdValidate.parameter(), (req, res, next) => { if (!req.auth(res, ['maintain', 'admin'], 'basic')) return; - SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).lean().exec((err, data) => { + SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).log(req).lean().exec((err, data) => { if (err) return next(err); if (!data) { @@ -202,7 +204,7 @@ router.put('/sample/validate/' + IdValidate.parameter(), (req, res, next) => { return res.status(400).json({status: 'Sample without measurements cannot be valid'}); } - SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.validated}).lean().exec(err => { + SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.validated}).log(req).lean().exec(err => { if (err) return next(err); res.json({status: 'OK'}); }); @@ -224,7 +226,7 @@ router.post('/sample/new', async (req, res, next) => { if (!await sampleRefCheck(sample, res, next)) return; if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields - customFieldsChange(Object.keys(sample.notes.custom_fields), 1); + customFieldsChange(Object.keys(sample.notes.custom_fields), 1, req); } if (!_.isEmpty(sample.condition)) { // do not execute check if condition is empty @@ -242,12 +244,14 @@ router.post('/sample/new', async (req, res, next) => { await new NoteModel(sample.notes).save((err, data) => { // save notes if (err) return next(err); + db.log(req, 'notes', {_id: data._id}, data.toObject()); delete sample.notes; sample.note_id = data._id; sample.user_id = req.authDetails.id; new SampleModel(sample).save((err, data) => { if (err) return next(err); + db.log(req, 'samples', {_id: data._id}, data.toObject()); res.json(SampleValidate.output(data.toObject())); }); }); @@ -330,7 +334,7 @@ async function conditionCheck (condition, param, res, next, checkVersion = true) function sampleRefCheck (sample, res, next) { // validate sample_references, resolves false for invalid reference return new Promise(resolve => { - if (sample.notes.sample_references.length > 0) { // there are sample_references + if (sample.notes.hasOwnProperty('sample_references') && sample.notes.sample_references.length > 0) { // there are sample_references let referencesCount = sample.notes.sample_references.length; // count to keep track of running async operations sample.notes.sample_references.forEach(reference => { @@ -353,17 +357,18 @@ function sampleRefCheck (sample, res, next) { // validate sample_references, re }); } -function customFieldsChange (fields, amount) { // update custom_fields and respective quantities +function customFieldsChange (fields, amount, req) { // update custom_fields and respective quantities fields.forEach(field => { - NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: amount}}, {new: true}).lean().exec((err, data: any) => { // check if field exists + NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: amount}} as any, {new: true}).log(req).lean().exec((err, data: any) => { // check if field exists if (err) return console.error(err); if (!data) { // new field - new NoteFieldModel({name: field, qty: 1}).save(err => { + new NoteFieldModel({name: field, qty: 1}).save((err, data) => { if (err) return console.error(err); + db.log(req, 'note_fields', {_id: data._id}, data.toObject()); }) } else if (data.qty <= 0) { // delete document if field is not used anymore - NoteFieldModel.findOneAndDelete({name: field}).lean().exec(err => { + NoteFieldModel.findOneAndDelete({name: field}).log(req).lean().exec(err => { if (err) return console.error(err); }); } diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts index cbc481a..cd90108 100644 --- a/src/routes/template.spec.ts +++ b/src/routes/template.spec.ts @@ -144,6 +144,22 @@ describe('/template', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/condition/200000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}, + log: { + collection: 'condition_templates', + dataAdd: { + first_id: '200000000000000000000001', + version: 2 + } + } + }); + }); it('allows changing only one property', done => { TestHelper.request(server, done, { method: 'put', @@ -328,6 +344,20 @@ describe('/template', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/condition/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}, + log: { + collection: 'condition_templates', + dataAdd: {version: 1}, + dataIgn: ['first_id'] + } + }); + }); it('rejects a missing name', done => { TestHelper.request(server, done, { method: 'post', @@ -552,7 +582,7 @@ describe('/template', () => { url: '/template/measurement/300000000000000000000001', auth: {basic: 'admin'}, httpStatus: 200, - req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]} }).end((err, res) => { if (err) return done(err); should(_.omit(res.body, '_id')).be.eql({name: 'IR spectrum', version: 2, parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}); @@ -571,6 +601,22 @@ describe('/template', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/template/measurement/300000000000000000000001', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}, + log: { + collection: 'measurement_templates', + dataAdd: { + first_id: '300000000000000000000001', + version: 2 + } + } + }); + }); it('allows changing only one property', done => { TestHelper.request(server, done, { method: 'put', @@ -747,6 +793,20 @@ describe('/template', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/template/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]}, + log: { + collection: 'measurement_templates', + dataAdd: {version: 1}, + dataIgn: ['first_id'] + } + }); + }); it('rejects a missing name', done => { TestHelper.request(server, done, { method: 'post', diff --git a/src/routes/template.ts b/src/routes/template.ts index 2873946..c3bd14b 100644 --- a/src/routes/template.ts +++ b/src/routes/template.ts @@ -7,6 +7,7 @@ import MeasurementTemplateModel from '../models/measurement_template'; import res400 from './validate/res400'; import IdValidate from './validate/id'; import mongoose from "mongoose"; +import db from '../db'; @@ -52,6 +53,7 @@ router.put('/template/:collection(measurement|condition)/' + IdValidate.paramete template.version = templateData.version + 1; // increase version await new (model(req))(_.assign({}, _.omit(templateData, ['_id', '__v']), template)).save((err, data) => { // save new template, fill with old properties if (err) next (err); + db.log(req, req.params.collection + '_templates', {_id: data._id}, data.toObject()); res.json(TemplateValidate.output(data.toObject())); }); } @@ -71,6 +73,7 @@ router.post('/template/:collection(measurement|condition)/new', async (req, res, template.version = 1; // set template version await new (model(req))(template).save((err, data) => { if (err) next (err); + db.log(req, req.params.collection + '_templates', {_id: data._id}, data.toObject()); res.json(TemplateValidate.output(data.toObject())); }); }); diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts index 917b734..79c0769 100644 --- a/src/routes/user.spec.ts +++ b/src/routes/user.spec.ts @@ -3,6 +3,7 @@ import UserModel from '../models/user'; import TestHelper from "../test/helper"; + describe('/user', () => { let server; before(done => TestHelper.before(done)); @@ -199,6 +200,19 @@ describe('/user', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'put', + url: '/user', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {name: 'adminnew', email: 'adminnew@bosch.com', pass: 'Abc123##', location: 'Abt', device_name: 'test'}, + log: { + collection: 'users', + dataIgn: ['pass'] + } + }); + }); it('lets the admin change a user level', done => { TestHelper.request(server, done, { method: 'put', @@ -370,6 +384,17 @@ describe('/user', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/user', + auth: {basic: 'janedoe'}, + httpStatus: 200, + log: { + collection: 'users' + } + }); + }); it('rejects requests from non-admins for another user', done => { TestHelper.request(server, done, { method: 'delete', @@ -482,6 +507,19 @@ describe('/user', () => { }); }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}, + log: { + collection: 'users', + dataIgn: ['pass', 'key'] + } + }); + }); it('rejects a username already in use', done => { TestHelper.request(server, done, { method: 'post', @@ -587,6 +625,18 @@ describe('/user', () => { res: {status: 'OK'} }); }); + it('creates a changelog', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/user/passreset', + httpStatus: 200, + req: {email: 'jane.doe@bosch.com', name: 'janedoe'}, + log: { + collection: 'users', + dataIgn: ['email', 'name', 'pass'] + } + }); + }); it('returns 404 for wrong username/email combo', done => { TestHelper.request(server, done, { method: 'post', diff --git a/src/routes/user.ts b/src/routes/user.ts index 6ebed4b..65c41d5 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -7,6 +7,7 @@ import UserValidate from './validate/user'; import UserModel from '../models/user'; import mail from '../helpers/mail'; import res400 from './validate/res400'; +import db from '../db'; const router = express.Router(); @@ -53,7 +54,7 @@ router.put('/user:username([/](?!key|new).?*|/?)', async (req, res, next) => { if (!await usernameCheck(user.name, res, next)) return; } - await UserModel.findOneAndUpdate({name: username}, user, {new: true}).lean().exec( (err, data:any) => { + await UserModel.findOneAndUpdate({name: username}, user, {new: true}).log(req).lean().exec( (err, data:any) => { if (err) return next(err); if (data) { res.json(UserValidate.output(data)); @@ -70,7 +71,7 @@ router.delete('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // const username = getUsername(req, res); if (!username) return; - UserModel.findOneAndDelete({name: username}).lean().exec( (err, data:any) => { + UserModel.findOneAndDelete({name: username}).log(req).lean().exec( (err, data:any) => { if (err) return next(err); if (data) { res.json({status: 'OK'}) @@ -105,6 +106,7 @@ router.post('/user/new', async (req, res, next) => { user.pass = hash; new UserModel(user).save((err, data) => { // store user if (err) return next(err); + db.log(req, 'users', {_id: data._id}, data.toObject()); res.json(UserValidate.output(data.toObject())); }); }); @@ -119,7 +121,7 @@ router.post('/user/passreset', (req, res, next) => { bcrypt.hash(newPass, 10, (err, hash) => { // password hashing if (err) return next(err); - UserModel.findByIdAndUpdate(data[0]._id, {pass: hash}, err => { // write new password + UserModel.findByIdAndUpdate(data[0]._id, {pass: hash}).log(req).exec(err => { // write new password if (err) return next(err); // send email diff --git a/src/routes/validate/root.ts b/src/routes/validate/root.ts new file mode 100644 index 0000000..3d05f9b --- /dev/null +++ b/src/routes/validate/root.ts @@ -0,0 +1,50 @@ +import Joi from '@hapi/joi'; +import IdValidate from './id'; + +export default class RootValidate { // validate input for root methods + private static changelog = { + timestamp: Joi.date() + .iso() + .min('1970-01-01T00:00:00.000Z'), + + page: Joi.number() + .integer() + .min(0) + .default(0), + + pagesize: Joi.number() + .integer() + .min(0) + .default(25), + + action: Joi.string(), + + collection: Joi.string(), + + conditions: Joi.object(), + + data: Joi.object() + }; + + static changelogParams (data) { + return Joi.object({ + timestamp: this.changelog.timestamp.required(), + page: this.changelog.page, + pagesize: this.changelog.pagesize + }).validate(data); + } + + static changelogOutput (data) { + data.date = data._id.getTimestamp(); + data.collection = data.collectionName; + data = IdValidate.stringify(data); + const {value, error} = Joi.object({ + date: this.changelog.timestamp, + action: this.changelog.action, + collection: this.changelog.collection, + conditions: this.changelog.conditions, + data: this.changelog.data, + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts index 9cb8cbb..58c33ba 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -69,7 +69,7 @@ export default class SampleValidate { } else if (param === 'new-admin') { return Joi.object({ - number: this.sample.number.required(), + number: this.sample.number, color: this.sample.color.required(), type: this.sample.type.required(), batch: this.sample.batch.required(), diff --git a/src/test/db.json b/src/test/db.json index ea2dd10..ef26a63 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -596,6 +596,64 @@ "key": "000000000000000000001004", "__v": 0 } + ], + "changelogs": [ + { + "_id" : {"$oid": "120000010000000000000000"}, + "action" : "PUT /sample/400000000000000000000001", + "collectionName" : "samples", + "conditions" : { + "_id" : {"$oid": "400000000000000000000001"} + }, + "data" : { + "type" : "part", + "status" : 0 + }, + "user_id" : {"$oid": "000000000000000000000003"}, + "__v" : 0 + }, + { + "_id" : {"$oid": "120000020000000000000000"}, + "action" : "PUT /sample/400000000000000000000001", + "collectionName" : "samples", + "conditions" : { + "_id" : {"$oid": "400000000000000000000001"} + }, + "data" : { + "type" : "part", + "status" : 0 + }, + "user_id" : {"$oid": "000000000000000000000003"}, + "__v" : 0 + }, + { + "_id" : {"$oid": "120000030000000000000000"}, + "action" : "PUT /sample/400000000000000000000001", + "collectionName" : "samples", + "conditions" : { + "_id" : {"$oid": "400000000000000000000001"} + }, + "data" : { + "type" : "part", + "status" : 0 + }, + "user_id" : {"$oid": "000000000000000000000003"}, + "__v" : 0 + }, + { + "_id" : {"$oid": "120000040000000000000000"}, + "action" : "PUT /sample/400000000000000000000001", + "collectionName" : "samples", + "conditions" : { + "_id" : {"$oid": "400000000000000000000001"} + }, + "data" : { + "type" : "part", + "status" : 0 + }, + "user_id" : {"$oid": "000000000000000000000003"}, + "__v" : 0 + } ] } } \ No newline at end of file diff --git a/src/test/helper.ts b/src/test/helper.ts index fbb45ff..e1e8eec 100644 --- a/src/test/helper.ts +++ b/src/test/helper.ts @@ -1,14 +1,17 @@ import supertest from 'supertest'; import should from 'should/as-function'; -import db from "../db"; +import _ from 'lodash'; +import db from '../db'; +import ChangelogModel from '../models/changelog'; +import IdValidate from '../routes/validate/id'; export default class TestHelper { public static auth = { // test user credentials - admin: {pass: 'Abc123!#', key: '000000000000000000001003'}, - janedoe: {pass: 'Xyz890*)', key: '000000000000000000001002'}, - user: {pass: 'Xyz890*)', key: '000000000000000000001001'}, - johnnydoe: {pass: 'Xyz890*)', key: '000000000000000000001004'} + admin: {pass: 'Abc123!#', key: '000000000000000000001003', id: '000000000000000000000003'}, + janedoe: {pass: 'Xyz890*)', key: '000000000000000000001002', id: '000000000000000000000002'}, + user: {pass: 'Xyz890*)', key: '000000000000000000001001', id: '000000000000000000000001'}, + johnnydoe: {pass: 'Xyz890*)', key: '000000000000000000001004', id: '000000000000000000000004'} } public static res = { // default responses @@ -92,6 +95,35 @@ export default class TestHelper { done(); }); } + else if (options.hasOwnProperty('log')) { // check changelog, takes log: {collection, skip, data/(dataAdd, dataIgn)} + return st.end(err => { + if (err) return done (err); + ChangelogModel.findOne({}).sort({_id: -1}).skip(options.log.skip? options.log.skip : 0).lean().exec((err, data) => { // latest entry + if (err) return done(err); + should(data).have.only.keys('_id', 'action', 'collectionName', 'conditions', 'data', 'user_id', '__v'); + should(data).have.property('action', options.method.toUpperCase() + ' ' + options.url); + should(data).have.property('collectionName', options.log.collection); + if (options.log.hasOwnProperty('data')) { + should(data).have.property('data', options.log.data); + } + else { + const ignore = ['_id', '__v']; + if (options.log.hasOwnProperty('dataIgn')) { + ignore.push(...options.log.dataIgn); + } + let tmp = options.req ? options.req : {}; + if (options.log.hasOwnProperty('dataAdd')) { + _.assign(tmp, options.log.dataAdd) + } + should(IdValidate.stringify(_.omit(data.data, ignore))).be.eql(_.omit(tmp, ignore)); + } + if (data.user_id) { + should(data.user_id.toString()).be.eql(this.auth[options.auth.basic].id); + } + done(); + }); + }); + } else { // return object to do .end() manually return st; }