diff --git a/api/api.yaml b/api/api.yaml index d85acc7..5d0ab02 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -65,6 +65,7 @@ tags: - name: /template - name: /model - name: /user + - name: /help paths: @@ -76,6 +77,7 @@ paths: - $ref: 'template.yaml' - $ref: 'model.yaml' - $ref: 'user.yaml' + - $ref: 'help.yaml' components: diff --git a/api/help.yaml b/api/help.yaml new file mode 100644 index 0000000..de36cbe --- /dev/null +++ b/api/help.yaml @@ -0,0 +1,60 @@ +/help/{key}: + parameters: + - $ref: 'api.yaml#/components/parameters/Key' + get: + summary: get help text for key + description: 'Auth: basic, levels: predict, read, write, dev, admin, depending on the set document level' + tags: + - /help + responses: + 200: + description: the required text + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Help' + 403: + $ref: 'api.yaml#/components/responses/403' + 404: + $ref: 'api.yaml#/components/responses/404' + 500: + $ref: 'api.yaml#/components/responses/500' + post: + summary: add/replace help text + description: 'Auth: basic, levels: dev, admin
If the given key exists, the item is replaced, + otherwise it is newly created' + tags: + - /help + requestBody: + required: true + content: + application/json: + schema: + $ref: 'api.yaml#/components/schemas/Help' + responses: + 200: + $ref: 'api.yaml#/components/responses/Ok' + 400: + $ref: 'api.yaml#/components/responses/400' + 401: + $ref: 'api.yaml#/components/responses/401' + 403: + $ref: 'api.yaml#/components/responses/403' + 500: + $ref: 'api.yaml#/components/responses/500' + delete: + summary: remove help text + description: 'Auth: basic, levels: dev, admin' + tags: + - /help + 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' \ No newline at end of file diff --git a/api/parameters.yaml b/api/parameters.yaml index 192b15a..a17127f 100644 --- a/api/parameters.yaml +++ b/api/parameters.yaml @@ -46,4 +46,13 @@ Group: required: true schema: type: string - example: vn \ No newline at end of file + example: vn + +Key: + name: key + description: URIComponent encoded string + in: path + required: true + schema: + type: string + example: '%2Fdocumentation%2Fdatabase' \ No newline at end of file diff --git a/api/schemas.yaml b/api/schemas.yaml index a61a819..1da048f 100644 --- a/api/schemas.yaml +++ b/api/schemas.yaml @@ -232,4 +232,14 @@ ModelItem: example: https://definma-model-test.apps.de1.bosch-iot-cloud.com/predict/model1-1 label: type: string - example: 'ml/g' \ No newline at end of file + example: 'ml/g' + +Help: + properties: + text: + type: string + example: This page documents the database. + level: + type: string + description: can be also null to allow access without authorization + example: read \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index a1c7417..a2165e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -114,6 +114,7 @@ app.use('/', require('./routes/measurement')); app.use('/', require('./routes/template')); app.use('/', require('./routes/model')); app.use('/', require('./routes/user')); +app.use('/', require('./routes/help')); // static files app.use('/static', express.static('static')); diff --git a/src/models/help.ts b/src/models/help.ts new file mode 100644 index 0000000..a04ae33 --- /dev/null +++ b/src/models/help.ts @@ -0,0 +1,16 @@ +import db from '../db'; +import mongoose from 'mongoose'; + +const HelpSchema = new mongoose.Schema({ + key: {type: String, index: {unique: true}}, + level: String, + text: String +}, {minimize: false}); + +// changelog query helper +HelpSchema.query.log = function > (req) { + db.log(req, this); + return this; +} + +export default mongoose.model>('help', HelpSchema); \ No newline at end of file diff --git a/src/routes/help.spec.ts b/src/routes/help.spec.ts new file mode 100644 index 0000000..590d47b --- /dev/null +++ b/src/routes/help.spec.ts @@ -0,0 +1,184 @@ +import should from 'should/as-function'; +import TestHelper from "../test/helper"; +import HelpModel from '../models/help'; + + +describe('/help', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + after(done => TestHelper.after(done)); + + describe('GET /help/{key}', () => { + it('returns the required text', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/%2Fsamples', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {text: 'Samples help', level: 'read'} + }); + }); + it('returns the required text without authorization if allowed', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/%2Fdocumentation', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {text: 'Documentation help', level: 'none'} + }); + }); + it('returns 404 for an invalid key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/documentation/database', + httpStatus: 404 + }); + }); + it('returns 404 for an unknown key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/xxx', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('returns 403 for a text with a higher level than given', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/%2Fmodels', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/%2Fsamples', + auth: {api: 'janedoe'}, + httpStatus: 401, + }); + }); + it('rejects an unauthorized request if a level is given', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/help/%2Fsamples', + httpStatus: 401 + }); + }); + }); + + describe('POST /help/{key}', () => { + it('changes the required text', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/help/%2Fsamples', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {text: 'New samples help', level: 'write'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + HelpModel.find({key: '/samples'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'key', 'text', 'level'); + should(data[0]).property('key', '/samples'); + should(data[0]).property('text', 'New samples help'); + should(data[0]).property('level', 'write'); + done(); + }); + }); + }); + it('saves a new text', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/help/%2Fmaterials', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {text: 'Materials help', level: 'dev'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + HelpModel.find({key: '/materials'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(1); + should(data[0]).have.only.keys('_id', 'key', 'text', 'level', '__v'); + should(data[0]).property('key', '/materials'); + should(data[0]).property('text', 'Materials help'); + should(data[0]).property('level', 'dev'); + done(); + }); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/help/%2Fsamples', + auth: {key: 'admin'}, + httpStatus: 401, + req: {text: 'New samples help', level: 'write'} + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/help/%2Fsamples', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {text: 'New samples help', level: 'write'} + }); + }); + it('rejects an unauthorized request', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/help/%2Fsamples', + httpStatus: 401, + req: {text: 'New samples help', level: 'write'} + }); + }); + }); + + describe('DELETE /help/{key}', () => { + it('deletes the required entry', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/help/%2Fsamples', + auth: {basic: 'admin'}, + httpStatus: 200 + }).end((err, res) => { + if (err) return done(err); + should(res.body).be.eql({status: 'OK'}); + HelpModel.find({key: '/materials'}).lean().exec((err, data) => { + if (err) return done(err); + should(data).have.lengthOf(0); + done(); + }); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/help/%2Fsamples', + auth: {key: 'admin'}, + httpStatus: 401 + }); + }); + it('rejects a write user', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/help/%2Fsamples', + auth: {basic: 'janedoe'}, + httpStatus: 403 + }); + }); + it('rejects an unauthorized request', done => { + TestHelper.request(server, done, { + method: 'delete', + url: '/help/%2Fsamples', + httpStatus: 401 + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/help.ts b/src/routes/help.ts new file mode 100644 index 0000000..3a2cb51 --- /dev/null +++ b/src/routes/help.ts @@ -0,0 +1,55 @@ +import express from 'express'; +import HelpModel from '../models/help'; +import HelpValidate from './validate/help'; +import res400 from './validate/res400'; +import globals from '../globals'; + +const router = express.Router(); + +router.get('/help/:key', (req, res, next) => { + const {error: paramError, value: key} = HelpValidate.params(req.params); + if (paramError) return res400(paramError, res); + + HelpModel.findOne(key).lean().exec((err, data) => { + if (err) return next(err); + + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + if (data.level !== 'none') { // check level + if (!req.auth(res, + Object.values(globals.levels).slice(Object.values(globals.levels).findIndex(e => e === data.level)) + , 'basic')) return; + } + res.json(HelpValidate.output(data)); + }) +}); + +router.post('/help/:key', (req, res, next) => { + if (!req.auth(res, ['dev', 'admin'], 'basic')) return; + const {error: paramError, value: key} = HelpValidate.params(req.params); + if (paramError) return res400(paramError, res); + const {error, value: help} = HelpValidate.input(req.body); + if (error) return res400(error, res); + + HelpModel.findOneAndUpdate(key, help, {upsert: true}).log(req).lean().exec(err => { + if (err) return next(err); + res.json({status: 'OK'}); + }); +}); + +router.delete('/help/:key', (req, res, next) => { + if (!req.auth(res, ['dev', 'admin'], 'basic')) return; + const {error: paramError, value: key} = HelpValidate.params(req.params); + if (paramError) return res400(paramError, res); + + HelpModel.findOneAndDelete(key).log(req).lean().exec((err, data) => { + if (err) return next(err); + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + res.json({status: 'OK'}); + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/validate/help.ts b/src/routes/validate/help.ts new file mode 100644 index 0000000..6dcf64a --- /dev/null +++ b/src/routes/validate/help.ts @@ -0,0 +1,34 @@ +import Joi from 'joi'; +import globals from '../../globals'; + +export default class HelpValidate { + private static help = { + text: Joi.string() + .allow('') + .max(8192), + + level: Joi.string() + .valid('none', ...Object.values(globals.levels)) + } + + static input (data) { + return Joi.object({ + text: this.help.text.required(), + level: this.help.level.required() + }).validate(data); + } + + static output (data) { + const {value, error} = Joi.object({ + text: this.help.text, + level: this.help.level + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } + + static params(data) { + return Joi.object({ + key: Joi.string().min(1).max(128) + }).validate(data); + } +} \ No newline at end of file diff --git a/src/test/db.json b/src/test/db.json index f6118d2..ea276da 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -893,6 +893,26 @@ "user_id" : {"$oid": "000000000000000000000003"}, "__v" : 0 } + ], + "helps": [ + { + "_id": {"$oid":"150000000000000000000001"}, + "key": "/documentation", + "text": "Documentation help", + "level": "none" + }, + { + "_id": {"$oid":"150000000000000000000002"}, + "key": "/samples", + "text": "Samples help", + "level": "read" + }, + { + "_id": {"$oid":"150000000000000000000003"}, + "key": "/models", + "text": "Models help", + "level": "dev" + } ] } } \ No newline at end of file