diff --git a/package-lock.json b/package-lock.json index 2132cf41..ea9aacc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "openhim-core", - "version": "8.1.1", + "version": "8.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index fc1041b5..60178dbc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openhim-core", "description": "The OpenHIM core application that provides logging and routing of http requests", - "version": "8.1.2", + "version": "8.2.0", "main": "./lib/server.js", "bin": { "openhim-core": "./bin/openhim-core.js" diff --git a/src/api/apps.js b/src/api/apps.js new file mode 100644 index 00000000..1e5b308f --- /dev/null +++ b/src/api/apps.js @@ -0,0 +1,151 @@ +'use strict' + +import logger from 'winston' + +import * as authorisation from './authorisation' +import {AppModelAPI} from '../model/apps' + +/* + Checks admin permission for create, update and delete operations. + Throws error if user does not have admin access +*/ +const checkUserPermission = (ctx, operation) => { + if (!authorisation.inGroup('admin', ctx.authenticated)) { + ctx.statusCode = 403 + throw Error( + `User ${ctx.authenticated.email} is not an admin, API access to ${operation} an app denied.` + ) + } +} + +/* + Returns app if it exists, if not it throws an error +*/ +const checkAppExists = async (ctx, appId) => { + const app = await AppModelAPI.findById(appId) + + if (!app) { + ctx.statusCode = 404 + throw Error(`App with id ${appId} does not exist`) + } + + return app +} + +// Creates error response for operations create, read, update and delete +const createErrorResponse = (ctx, operation, error) => { + logger.error(`Could not ${operation} an app via the API: ${error.message}`) + + ctx.body = { + error: error.message + } + ctx.status = ctx.statusCode ? ctx.statusCode : 500 +} + +const validateId = (ctx, id) => { + if (!id.match(/^[0-9a-fA-F]{24}$/)) { + ctx.statusCode = 400 + throw Error(`App id "${id}" is invalid. ObjectId should contain 24 characters`) + } +} + +export async function addApp(ctx) { + try { + checkUserPermission(ctx, 'add') + + const app = new AppModelAPI(ctx.request.body) + + await app + .save() + .then(app => { + logger.info(`User ${ctx.request.email} created app ${app.name}`) + + ctx.status = 201 + ctx.body = app + }) + .catch(e => { + ctx.statusCode = 400 + throw e + }) + } catch (e) { + createErrorResponse(ctx, 'add', e) + } +} + +export async function updateApp(ctx, appId) { + try { + checkUserPermission(ctx, 'update') + + validateId(ctx, appId) + + await checkAppExists(ctx, appId) + + const update = ctx.request.body + + await AppModelAPI.findOneAndUpdate({_id: appId}, update, { + new: true, + runValidators: true + }) + .then(app => { + logger.info(`User ${ctx.authenticated.email} updated app ${app.name}`) + + ctx.body = app + ctx.status = 200 + }) + .catch(e => { + ctx.statusCode = 400 + throw e + }) + } catch (e) { + createErrorResponse(ctx, 'update', e) + } +} + +export async function getApps(ctx) { + try { + const apps = await AppModelAPI.find(ctx.request.query) + + logger.info(`User ${ctx.authenticated.email} fetched ${apps.length} apps`) + + ctx.body = apps + ctx.status = 200 + } catch (e) { + createErrorResponse(ctx, 'retrieve', e) + } +} + +export async function getApp(ctx, appId) { + try { + validateId(ctx, appId) + + const app = await checkAppExists(ctx, appId) + + logger.info(`User ${ctx.authenticated.email} app fetched ${appId}`) + + ctx.body = app + ctx.status = 200 + } catch (e) { + createErrorResponse(ctx, 'retrieve', e) + } +} + +export async function deleteApp(ctx, appId) { + try { + checkUserPermission(ctx, 'delete') + + validateId(ctx, appId) + + await checkAppExists(ctx, appId) + + await AppModelAPI.deleteOne({_id: appId}).then(() => { + logger.info(`User ${ctx.authenticated.email} deleted app ${appId}`) + + ctx.status = 200 + ctx.body = { + success: true + } + }) + } catch (e) { + createErrorResponse(ctx, 'delete', e) + } +} diff --git a/src/koaApi.js b/src/koaApi.js index b7ae8736..e369da68 100644 --- a/src/koaApi.js +++ b/src/koaApi.js @@ -8,6 +8,7 @@ import session from 'koa-session' import compose from 'koa-compose' import * as about from './api/about' +import * as apps from './api/apps' import * as audits from './api/audits' import * as authentication from './api/authentication' import * as certificateAuthority from './api/certificateAuthority' @@ -136,6 +137,12 @@ export function setupApp(done) { app.use(route.get('/logout', users.logout)) // Define the api routes + app.use(route.get('/apps', apps.getApps)) + app.use(route.get('/apps/:appId', apps.getApp)) + app.use(route.put('/apps/:appId', apps.updateApp)) + app.use(route.post('/apps', apps.addApp)) + app.use(route.delete('/apps/:appId', apps.deleteApp)) + app.use(route.get('/users', users.getUsers)) app.use(route.get('/users/:email', users.getUser)) app.use(route.post('/users', users.addUser)) diff --git a/src/model/apps.js b/src/model/apps.js new file mode 100644 index 00000000..bd1104b9 --- /dev/null +++ b/src/model/apps.js @@ -0,0 +1,33 @@ +'use strict' + +import {Schema} from 'mongoose' + +import {connectionAPI, connectionDefault} from '../config' + +const AppSchema = new Schema({ + name: { + type: String, + unique: true, + required: true + }, + description: String, + icon: { + data: Buffer, + contentType: String + }, + category: String, + access_roles: [String], + url: { + type: String, + unique: true, + required: true + }, + showInPortal: { + type: Boolean, + default: true + }, + showInSideBar: Boolean +}) + +export const AppModelAPI = connectionAPI.model('App', AppSchema) +export const AppModel = connectionDefault.model('App', AppSchema) diff --git a/test/integration/appsAPITests.js b/test/integration/appsAPITests.js new file mode 100644 index 00000000..7f448cb7 --- /dev/null +++ b/test/integration/appsAPITests.js @@ -0,0 +1,265 @@ +'use strict' + +/* eslint-env mocha */ +/* eslint no-unused-expressions:0 */ + +import request from 'supertest' +import should from 'should' +import {promisify} from 'util' + +import * as constants from '../constants' +import * as server from '../../src/server' +import * as testUtils from '../utils' +import {AppModelAPI} from '../../src/model/apps' + +const {SERVER_PORTS, BASE_URL} = constants + +describe('API Integration Tests', () => { + describe('Apps REST Api Testing', () => { + const testAppDoc = { + name: 'Test app', + description: 'An app for testing the app framework', + icon: 'data:image/png;base64, ', + type: 'link|embedded', + category: 'Operations', + access_roles: ['test-app-user'], + url: 'http://test-app.org/app', + showInPortal: true, + showInSideBar: true + } + let rootCookie = '', + nonRootCookie = '' + + before(async () => { + await promisify(server.start)({apiPort: SERVER_PORTS.apiPort}) + await testUtils.setupTestUsers() + }) + + after(async () => { + await testUtils.cleanupTestUsers() + await promisify(server.stop)() + }) + + beforeEach(async () => { + rootCookie = await testUtils.authenticate( + request, + BASE_URL, + testUtils.rootUser + ) + nonRootCookie = await testUtils.authenticate( + request, + BASE_URL, + testUtils.nonRootUser + ) + }) + + afterEach(async () => { + await AppModelAPI.deleteMany({}) + }) + + describe('*addApp', () => { + it('should only allow an admin user to add an app', async () => { + const res = await request(BASE_URL) + .post('/apps') + .set('Cookie', nonRootCookie) + .send(testAppDoc) + .expect(403) + + res.body.error.should.equal( + 'User nonroot@jembi.org is not an admin, API access to add an app denied.' + ) + }) + + it('should fail when app is invalid', async () => { + await request(BASE_URL) + .post('/apps') + .set('Cookie', rootCookie) + .send({}) + .expect(400) + }) + + it('should create an app', async () => { + const res = await request(BASE_URL) + .post('/apps') + .set('Cookie', rootCookie) + .send(testAppDoc) + .expect(201) + + res.body.name.should.equal(testAppDoc.name) + }) + }) + + describe('*getApps', () => { + let appId + + beforeEach(async () => { + const res = await request(BASE_URL) + .post('/apps') + .set('Cookie', rootCookie) + .send(testAppDoc) + .expect(201) + + appId = res.body._id + }) + + it('should get apps', async () => { + const res = await request(BASE_URL) + .get('/apps') + .set('Cookie', rootCookie) + .expect(200) + + res.body[0].name.should.equal(testAppDoc.name) + }) + + it('should get app', async () => { + const res = await request(BASE_URL) + .get(`/apps/${appId}`) + .set('Cookie', rootCookie) + .expect(200) + + res.body.name.should.equal(testAppDoc.name) + }) + + it('should fail when app id is invalid', async () => { + const res = await request(BASE_URL) + .put(`/apps/testapp`) + .set('Cookie', rootCookie) + .expect(400) + + res.body.error.should.equal( + 'App id "testapp" is invalid. ObjectId should contain 24 characters' + ) + }) + + it('should fail when app does not exist', async () => { + const res = await request(BASE_URL) + .get('/apps/507f1f77bcf86cd799439011') + .set('Cookie', nonRootCookie) + .expect(404) + + res.body.error.should.equal( + 'App with id 507f1f77bcf86cd799439011 does not exist' + ) + }) + }) + + describe('*updateApp', () => { + const update = { + description: 'Test app' + } + + let appId + + beforeEach(async () => { + const res = await request(BASE_URL) + .post('/apps') + .set('Cookie', rootCookie) + .send(testAppDoc) + .expect(201) + + appId = res.body._id + }) + + it('should only allow an admin user to update an app', async () => { + const res = await request(BASE_URL) + .put('/apps/507f1f77bcf86cd799439011') + .set('Cookie', nonRootCookie) + .send(update) + .expect(403) + + res.body.error.should.equal( + 'User nonroot@jembi.org is not an admin, API access to update an app denied.' + ) + }) + + it('should fail to update when app id is invalid', async () => { + const res = await request(BASE_URL) + .put(`/apps/testapp`) + .set('Cookie', rootCookie) + .expect(400) + + res.body.error.should.equal( + 'App id "testapp" is invalid. ObjectId should contain 24 characters' + ) + }) + + it('should fail to update when app does not exist', async () => { + const res = await request(BASE_URL) + .put(`/apps/507f1f77bcf86cd799439011`) + .set('Cookie', rootCookie) + .send(update) + .expect(404) + + res.body.error.should.equal( + 'App with id 507f1f77bcf86cd799439011 does not exist' + ) + }) + + it('should update app', async () => { + const res = await request(BASE_URL) + .put(`/apps/${appId}`) + .set('Cookie', rootCookie) + .send(update) + .expect(200) + + res.body.description.should.equal(update.description) + }) + }) + + describe('*deleteApp', () => { + let appId + + beforeEach(async () => { + const res = await request(BASE_URL) + .post('/apps') + .set('Cookie', rootCookie) + .send(testAppDoc) + .expect(201) + + appId = res.body._id + }) + + it('should only allow an admin user to delete an app', async () => { + const res = await request(BASE_URL) + .delete('/apps/507f1f77bcf86cd799439011') + .set('Cookie', nonRootCookie) + .expect(403) + + res.body.error.should.equal( + 'User nonroot@jembi.org is not an admin, API access to delete an app denied.' + ) + }) + + it('should fail to delete when app id is invalid', async () => { + const res = await request(BASE_URL) + .delete(`/apps/testapp`) + .set('Cookie', rootCookie) + .expect(400) + + res.body.error.should.equal( + 'App id "testapp" is invalid. ObjectId should contain 24 characters' + ) + }) + + it('should fail to delete when app does not exist', async () => { + const res = await request(BASE_URL) + .delete('/apps/507f1f77bcf86cd799439011') + .set('Cookie', rootCookie) + .expect(404) + + res.body.error.should.equal( + 'App with id 507f1f77bcf86cd799439011 does not exist' + ) + }) + + it('should delete app', async () => { + const res = await request(BASE_URL) + .delete(`/apps/${appId}`) + .set('Cookie', rootCookie) + .expect(200) + + res.body.success.should.equal(true) + }) + }) + }) +})