openhim-core
Version:
The OpenHIM core application that provides logging and routing of http requests
744 lines (643 loc) • 24.6 kB
JavaScript
/* eslint-env mocha */
/* eslint no-unused-expressions:0 */
import fs from 'fs'
import sinon from 'sinon'
import * as router from '../../src/middleware/router'
import * as testUtils from '../utils'
import { KeystoreModel, CertificateModel } from '../../src/model'
import * as constants from '../constants'
import { promisify } from 'util'
const DEFAULT_CHANNEL = Object.freeze({
name: 'Mock endpoint',
urlPattern: '.+',
routes: [{
host: 'localhost',
port: constants.HTTP_PORT,
primary: true
}
]
})
describe('HTTP Router', () => {
const requestTimestamp = (new Date()).toString()
before(() => testUtils.setupTestKeystore())
after(() => testUtils.cleanupTestKeystore())
function createContext (channel, path = '/test', method = 'GET', body = undefined) {
return {
authorisedChannel: testUtils.clone(channel),
request: {
method
},
response: {
set: sinon.spy()
},
path,
requestTimestamp,
body
}
}
describe('.route', () => {
describe('single route', () => {
let server
afterEach(async () => {
if (server != null) {
await server.close()
server = null
}
})
it('should route an incomming request to the endpoints specific by the channel config', async () => {
const respBody = 'Hi I am the response\n'
const ctx = createContext(DEFAULT_CHANNEL)
server = await testUtils.createMockHttpServer(respBody)
await promisify(router.route)(ctx)
ctx.response.status.should.be.exactly(201)
ctx.response.body.toString().should.be.eql(respBody)
ctx.response.header.should.be.ok
})
it('should route binary data', async () => {
server = await testUtils.createStaticServer()
const channel = {
name: 'Static Server Endpoint',
urlPattern: '/openhim-logo-green.png',
routes: [{
host: 'localhost',
port: constants.STATIC_PORT,
primary: true
}
]
}
const ctx = createContext(channel, '/openhim-logo-green.png')
await promisify(router.route)(ctx)
ctx.response.type.should.equal('image/png')
ctx.response.body.toString().should.equal((fs.readFileSync('test/resources/openhim-logo-green.png')).toString())
})
it('should route an incoming https request to the endpoints specific by the channel config', async () => {
server = await testUtils.createMockHttpsServer()
const keystore = await KeystoreModel.findOne({})
const cert = new CertificateModel({
data: fs.readFileSync('test/resources/server-tls/cert.pem')
})
keystore.ca.push(cert)
await keystore.save()
const channel = {
name: 'Mock endpoint',
urlPattern: '.+',
routes: [{
secured: true,
host: 'localhost',
port: constants.HTTPS_PORT,
primary: true,
cert: cert._id
}
]
}
const ctx = createContext(channel)
await promisify(router.route)(ctx)
ctx.response.status.should.be.exactly(201)
ctx.response.body.toString().should.be.eql(constants.DEFAULT_HTTPS_RESP)
ctx.response.header.should.be.ok
})
it('should be denied access if the server doesn\'t know the client cert when using mutual TLS authentication', async () => {
server = await testUtils.createMockHttpsServer('This is going to break', false)
const keystore = await KeystoreModel.findOne({})
const cert = new CertificateModel({
data: fs.readFileSync('test/resources/server-tls/cert.pem')
})
keystore.ca.push(cert)
await keystore.save()
const channel = {
name: 'Mock endpoint',
urlPattern: '.+',
routes: [{
secured: true,
host: 'localhost',
port: constants.HTTPS_PORT,
primary: true,
cert: cert._id
}
]
}
const ctx = createContext(channel)
await promisify(router.route)(ctx)
ctx.response.status.should.be.exactly(500)
ctx.response.body.toString().should.be.eql('An internal server error occurred')
})
it('should forward PUT and POST requests correctly', async () => {
const response = 'Hello Post'
const postSpy = sinon.spy(req => response)
server = await testUtils.createMockHttpServer(postSpy, constants.HTTP_PORT, 200)
const channel = {
name: 'POST channel',
urlPattern: '.+',
routes: [{
host: 'localhost',
port: constants.HTTP_PORT,
primary: true
}
]
}
const ctx = createContext(channel, '/test', 'POST', 'some body')
await promisify(router.route)(ctx)
Buffer.isBuffer(ctx.response.body).should.be.true()
ctx.response.body.toString().should.eql(response)
postSpy.callCount.should.be.eql(1)
const call = postSpy.getCall(0)
const req = call.args[0]
req.method.should.eql('POST')
})
it('should handle empty put and post requests correctly', async () => {
const response = 'Hello Empty Post'
const postSpy = sinon.spy(req => response)
server = await testUtils.createMockHttpServer(postSpy, constants.HTTP_PORT, 200)
const channel = {
name: 'POST channel',
urlPattern: '.+',
routes: [{
host: 'localhost',
port: constants.HTTP_PORT,
primary: true
}
]
}
const ctx = createContext(channel, '/test', 'POST')
await promisify(router.route)(ctx)
Buffer.isBuffer(ctx.response.body).should.be.true()
ctx.response.body.toString().should.eql(response)
postSpy.callCount.should.be.eql(1)
const call = postSpy.getCall(0)
const req = call.args[0]
req.method.should.eql('POST')
})
it('should send request params if these where received from the incoming request', async () => {
const requestSpy = sinon.spy((req) => { })
server = await testUtils.createMockHttpServer(requestSpy, constants.HTTP_PORT, 200)
const ctx = createContext(DEFAULT_CHANNEL)
ctx.request.querystring = 'parma1=val1&parma2=val2'
await promisify(router.route)(ctx)
requestSpy.callCount.should.be.eql(1)
const call = requestSpy.getCall(0)
const req = call.args[0]
req.url.should.eql('/test?parma1=val1&parma2=val2')
})
it('should set mediator response object on ctx', async () => {
server = await testUtils.createMockHttpMediator()
const channel = {
name: 'Mock endpoint',
urlPattern: '.+',
routes: [{
host: 'localhost',
port: constants.MEDIATOR_PORT,
primary: true
}
]
}
const ctx = createContext(channel)
await promisify(router.route)(ctx)
ctx.mediatorResponse.should.exist
ctx.mediatorResponse.should.eql(constants.MEDIATOR_REPONSE)
})
it('should set mediator response data as response to client', async () => {
const mediatorResponse = Object.assign({},
constants.mediatorResponse,
{
status: 'Failed',
response: {
status: 400,
headers: { 'content-type': 'text/xml', 'another-header': 'xyz' },
body: 'Mock response body from mediator\n'
}
})
server = await testUtils.createMockHttpMediator(mediatorResponse)
const channel = {
name: 'Mock endpoint',
urlPattern: '.+',
routes: [{
host: 'localhost',
port: constants.MEDIATOR_PORT,
primary: true
}
]
}
const ctx = createContext(channel)
await promisify(router.route)(ctx)
ctx.response.status.should.be.exactly(400)
ctx.response.body.should.be.exactly('Mock response body from mediator\n')
ctx.response.type.should.be.exactly('text/xml')
ctx.response.set.calledWith('another-header', 'xyz').should.be.true()
})
it('should set mediator response location header if present and status is not 3xx', async () => {
const mediatorResponse = Object.assign({},
constants.mediatorResponse,
{
status: 'Successful',
response: {
status: 201,
headers: { location: 'Patient/1/_history/1' },
body: 'Mock response body\n'
}
})
server = await testUtils.createMockHttpMediator(mediatorResponse)
const channel = {
name: 'Mock endpoint',
urlPattern: '.+',
routes: [{
host: 'localhost',
port: constants.MEDIATOR_PORT,
primary: true
}
]
}
const ctx = createContext(channel)
await promisify(router.route)(ctx)
ctx.response.set.calledWith('location', mediatorResponse.response.headers.location).should.be.true()
})
})
describe('multiroute', () => {
let servers = []
afterEach(async () => {
await Promise.all(servers.map(s => s.close()))
servers.length = 0
})
const NON_PRIMARY1_PORT = constants.PORT_START + 101
const NON_PRIMARY2_PORT = constants.PORT_START + 102
const PRIMARY_PORT = constants.PORT_START + 103
const channel = {
name: 'Multicast 1',
urlPattern: 'test/multicast.+',
routes: [{
name: 'non_primary_1',
host: 'localhost',
port: NON_PRIMARY1_PORT
},
{
name: 'primary',
host: 'localhost',
port: PRIMARY_PORT,
primary: true
},
{
name: 'non_primary_2',
host: 'localhost',
port: NON_PRIMARY2_PORT
}
]
}
it('should be able to multicast to multiple endpoints but return only the response from the primary route', async () => {
servers = await Promise.all([
testUtils.createMockHttpServer('Non Primary 1', NON_PRIMARY1_PORT, 200),
testUtils.createMockHttpServer('Non Primary 2', NON_PRIMARY2_PORT, 400),
testUtils.createMockHttpServer('Primary', PRIMARY_PORT, 201)
])
const ctx = createContext(channel, '/test/multicasting')
await promisify(router.route)(ctx)
await testUtils.setImmediatePromise()
ctx.response.status.should.be.exactly(201)
ctx.response.body.toString().should.be.eql('Primary')
ctx.response.header.should.be.ok
})
it('should be able to multicast to multiple endpoints and set the responses for non-primary routes in ctx.routes', async () => {
servers = await Promise.all([
testUtils.createMockHttpServer('Non Primary 1', NON_PRIMARY1_PORT, 200),
testUtils.createMockHttpServer('Non Primary 2', NON_PRIMARY2_PORT, 400),
testUtils.createMockHttpServer('Primary', PRIMARY_PORT, 201)
])
const ctx = createContext(channel, '/test/multicasting')
await promisify(router.route)(ctx)
await testUtils.setImmediatePromise()
ctx.routes.length.should.be.exactly(2)
ctx.routes[0].response.status.should.be.exactly(200)
ctx.routes[0].response.body.toString().should.be.eql('Non Primary 1')
ctx.routes[0].response.headers.should.be.ok
ctx.routes[0].request.path.should.be.exactly('/test/multicasting')
ctx.routes[0].request.timestamp.should.be.exactly(requestTimestamp)
ctx.routes[1].response.status.should.be.exactly(400)
ctx.routes[1].response.body.toString().should.be.eql('Non Primary 2')
ctx.routes[1].response.headers.should.be.ok
ctx.routes[1].request.path.should.be.exactly('/test/multicasting')
ctx.routes[1].request.timestamp.should.be.exactly(requestTimestamp)
})
it('should pass an error to next if there are multiple primary routes', async () => {
servers = await Promise.all([
testUtils.createMockHttpServer('Non Primary 1', NON_PRIMARY1_PORT, 200),
testUtils.createMockHttpServer('Non Primary 2', NON_PRIMARY2_PORT, 400),
testUtils.createMockHttpServer('Primary', PRIMARY_PORT, 201)
])
const ctx = createContext(channel, '/test/multicasting')
ctx.authorisedChannel.routes.forEach(r => {
r.primary = true
})
await promisify(router.route)(ctx).should.be.rejectedWith('Cannot route transaction: Channel contains multiple primary routes and only one primary is allowed')
})
it('should set mediator response data for non-primary routes', async () => {
const mediatorResponse = Object.assign({},
constants.MEDIATOR_REPONSE,
{
status: 'Failed',
response: {
status: 400,
headers: {},
body: 'Mock response body from mediator\n'
}
})
const channel = {
name: 'Mock endpoint',
urlPattern: '.+',
routes: [{
name: 'non prim',
host: 'localhost',
port: NON_PRIMARY1_PORT
},
{
name: 'primary',
host: 'localhost',
port: PRIMARY_PORT,
primary: true
}
]
}
servers = await Promise.all([
testUtils.createMockHttpMediator(mediatorResponse, PRIMARY_PORT),
testUtils.createMockHttpMediator(mediatorResponse, NON_PRIMARY1_PORT)
])
const ctx = createContext(channel, '/test/multicasting')
await promisify(router.route)(ctx)
ctx.routes[0].response.body.toString().should.be.eql('Mock response body from mediator\n')
ctx.routes[0].orchestrations.should.be.eql(mediatorResponse.orchestrations)
ctx.routes[0].properties.should.be.eql(mediatorResponse.properties)
ctx.routes[0].name.should.be.eql('non prim')
})
})
describe('methods', () => {
let mockServer
const sandbox = sinon.createSandbox()
const spy = sandbox.spy()
before(async () => {
mockServer = await testUtils.createMockHttpServer(spy)
})
afterEach(async () => {
sandbox.reset()
})
after(async () => {
if (mockServer != null) {
await mockServer.close()
mockServer = null
}
})
it('will reject methods that are not allowed', async () => {
const channel = Object.assign(testUtils.clone(DEFAULT_CHANNEL), { methods: ['GET', 'PUT'] })
const ctx = createContext(channel, undefined, 'POST')
await promisify(router.route)(ctx)
ctx.response.status.should.eql(405)
ctx.response.timestamp.should.Date()
ctx.response.body.should.eql(`Request with method POST is not allowed. Only GET, PUT methods are allowed`)
spy.callCount.should.eql(0)
})
it('will allow methods that are allowed', async () => {
const channel = Object.assign(testUtils.clone(DEFAULT_CHANNEL), { methods: ['GET', 'PUT'] })
const ctx = createContext(channel, undefined, 'GET')
await promisify(router.route)(ctx)
ctx.response.status.should.eql(201)
spy.callCount.should.eql(1)
})
it('will allow all methods if methods is empty', async () => {
const channel = Object.assign(testUtils.clone(DEFAULT_CHANNEL), { methods: [] })
const ctx = createContext(channel, undefined, 'GET')
await promisify(router.route)(ctx)
ctx.response.status.should.eql(201)
spy.callCount.should.eql(1)
})
})
})
describe('Basic Auth', () => {
let server
afterEach(async () => {
if (server != null) {
await server.close()
server = null
}
})
it('should have valid authorization header if username and password is set in options', async () => {
const requestSpy = sinon.spy((req) => { })
server = await testUtils.createMockHttpServer(requestSpy)
const channel = {
name: 'Mock endpoint',
urlPattern: '.+',
routes: [{
host: 'localhost',
port: constants.HTTP_PORT,
primary: true,
username: 'username',
password: 'password'
}
]
}
const ctx = createContext(channel)
await promisify(router.route)(ctx)
requestSpy.callCount.should.be.eql(1)
const call = requestSpy.getCall(0)
const req = call.args[0]
req.headers.authorization.should.be.exactly('Basic dXNlcm5hbWU6cGFzc3dvcmQ=')
})
it('should not have authorization header if username and password is absent from options', async () => {
const requestSpy = sinon.spy((req) => { })
server = await testUtils.createMockHttpServer(requestSpy)
const ctx = createContext(DEFAULT_CHANNEL)
await promisify(router.route)(ctx)
requestSpy.callCount.should.be.eql(1)
const call = requestSpy.getCall(0)
const req = call.args[0]
req.headers.should.not.have.property('authorization')
})
it('should not propagate the authorization header present in the request headers', async () => {
const requestSpy = sinon.spy((req) => { })
server = await testUtils.createMockHttpServer(requestSpy)
const ctx = createContext(DEFAULT_CHANNEL)
ctx.request.header = { authorization: 'Basic bWU6bWU=' }
await promisify(router.route)(ctx)
requestSpy.callCount.should.be.eql(1)
const call = requestSpy.getCall(0)
const req = call.args[0]
req.headers.should.not.have.property('authorization')
})
it('should propagate the authorization header present in the request headers if forwardAuthHeader is set to true', async () => {
const requestSpy = sinon.spy((req) => { })
server = await testUtils.createMockHttpServer(requestSpy)
const channel = testUtils.clone(DEFAULT_CHANNEL)
channel.routes[0].forwardAuthHeader = true
const ctx = createContext(channel)
ctx.request.header = { authorization: 'Basic bWU6bWU=' }
await promisify(router.route)(ctx)
requestSpy.callCount.should.be.eql(1)
const call = requestSpy.getCall(0)
const req = call.args[0]
req.headers.should.have.property('authorization')
req.headers.authorization.should.eql('Basic bWU6bWU=')
})
it('should have valid authorization header if username and password is set in options', async () => {
const requestSpy = sinon.spy((req) => { })
server = await testUtils.createMockHttpServer(requestSpy)
const channel = {
name: 'Mock endpoint',
urlPattern: '.+',
routes: [{
host: 'localhost',
port: constants.HTTP_PORT,
primary: true,
username: 'username',
password: 'password'
}
]
}
const ctx = createContext(channel)
ctx.request.header = { authorization: 'Basic bWU6bWU=' }
await promisify(router.route)(ctx)
requestSpy.callCount.should.be.eql(1)
const call = requestSpy.getCall(0)
const req = call.args[0]
req.headers.authorization.should.be.exactly('Basic dXNlcm5hbWU6cGFzc3dvcmQ=')
})
})
describe('Path Redirection', () => {
let server
afterEach(async () => {
if (server != null) {
await server.close()
}
server = null
})
it('should redirect the request to a specific path', async () => {
const channel = testUtils.clone(DEFAULT_CHANNEL)
const [route] = channel.routes
route.path = '/target'
const requestSpy = sinon.spy()
server = await testUtils.createMockHttpServer(requestSpy)
const ctx = createContext(channel, '/test')
await promisify(router.route)(ctx)
requestSpy.callCount.should.be.eql(1)
const call = requestSpy.getCall(0)
const req = call.args[0]
req.url.should.be.exactly(route.path)
})
it('should redirect the request to the transformed path', async () => {
const channel = testUtils.clone(DEFAULT_CHANNEL)
const [route] = channel.routes
route.pathTransform = 's/test/target'
const requestSpy = sinon.spy()
server = await testUtils.createMockHttpServer(requestSpy)
const ctx = createContext(channel, '/test')
await promisify(router.route)(ctx)
requestSpy.callCount.should.be.eql(1)
const call = requestSpy.getCall(0)
const req = call.args[0]
req.url.should.be.exactly('/target')
})
describe('.transformPath', () =>
it('must transform the path string correctly', () => {
const test = (path, expr, res) => router.transformPath(path, expr).should.be.exactly(res)
test('foo', 's/foo/bar', 'bar')
test('foo', 's/foo/', '')
test('foo', 's/o/e/g', 'fee')
test('foofoo', 's/foo//g', '')
test('foofoofoo', 's/foo/bar', 'barfoofoo')
test('foofoofoo', 's/foo/bar/g', 'barbarbar')
test('foo/bar', 's/foo/bar', 'bar/bar')
test('foo/bar', 's/foo\\/bar/', '')
test('foo/foo/bar/bar', 's/\\/foo\\/bar/', 'foo/bar')
test('prefix/foo/bar', 's/prefix\\//', 'foo/bar')
})
)
})
describe('setKoaResponse', () => {
const createCtx = function () {
const ctx = {}
ctx.response = {}
ctx.response.set = sinon.spy()
return ctx
}
const createResponse = function () {
return {
status: 201,
headers: {
'content-type': 'text/xml',
'x-header': 'anotherValue'
},
timestamp: new Date(),
body: 'Mock response body'
}
}
it('should set the ctx.response object', () => {
// given
const ctx = createCtx()
const response = createResponse()
// when
router.setKoaResponse(ctx, response)
// then
ctx.response.status.should.be.exactly(response.status)
ctx.response.body.should.be.exactly(response.body)
return ctx.response.timestamp.should.be.exactly(response.timestamp)
})
it('should copy response headers to the ctx.response object', () => {
// given
const ctx = createCtx()
const response = createResponse()
// when
router.setKoaResponse(ctx, response)
// then
ctx.response.set.calledWith('x-header', 'anotherValue').should.be.true()
})
it('should redirect the context if needed', () => {
// given
const ctx = createCtx()
ctx.response.redirect = sinon.spy()
const response = {
status: 301,
headers: {
'content-type': 'text/xml',
'x-header': 'anotherValue',
location: 'http://some.other.place.org'
},
timestamp: new Date(),
body: 'Mock response body'
}
// when
router.setKoaResponse(ctx, response)
// then
ctx.response.redirect.calledWith('http://some.other.place.org').should.be.true()
})
it('should not redirect if a non-redirect status is recieved', () => {
// given
const ctx = createCtx()
ctx.response.redirect = sinon.spy()
const response = {
status: 201,
headers: {
'content-type': 'text/xml',
'x-header': 'anotherValue',
location: 'http://some.other.place.org'
},
timestamp: new Date(),
body: 'Mock response body'
}
// when
router.setKoaResponse(ctx, response)
// then
ctx.response.redirect.calledWith('http://some.other.place.org').should.be.false()
})
it('should set cookies on context', () => {
const ctx = createCtx()
ctx.cookies = {}
ctx.cookies.set = sinon.spy()
const response = {
status: 201,
headers: {
'content-type': 'text/xml',
'x-header': 'anotherValue',
'set-cookie': ['maximus=Thegreat; max-age=18']
},
timestamp: new Date(),
body: 'Mock response body'
}
router.setKoaResponse(ctx, response)
ctx.cookies.set.calledWith("maximus", "Thegreat", {path: false, httpOnly: false, maxage: 18}).should.be.true()
})
})
})