openhim-core
Version:
The OpenHIM core application that provides logging and routing of http requests
503 lines (432 loc) • 19.3 kB
JavaScript
/* eslint-env mocha */
import should from 'should' // eslint-disable-line no-unused-vars
import request from 'supertest'
import * as server from '../../src/server'
import { AuditModel } from '../../src/model/audits'
import * as testUtils from '../utils'
import * as constants from '../constants'
import { promisify } from 'util'
import { ObjectId } from 'mongodb'
import https from 'https'
import fs from 'fs'
import { ChannelModelAPI } from '../../src/model/channels'
import { ClientModelAPI } from '../../src/model/clients'
import { KeystoreModelAPI } from '../../src/model/keystore'
import { config } from '../../src/config'
const { SERVER_PORTS } = constants
describe('API Integration Tests', () => {
describe('Authentication API tests', () => {
let authDetails = null
before(async () => {
await testUtils.setupTestUsers()
authDetails = testUtils.getAuthDetails()
const startPromise = promisify(server.start)
await startPromise({ apiPort: SERVER_PORTS.apiPort })
await testUtils.setImmediatePromise()
await AuditModel.deleteMany({})
})
afterEach(async () => {
await AuditModel.deleteMany({})
})
after(async () => {
await Promise.all([
testUtils.cleanupTestUsers(),
promisify(server.stop)()
])
})
it('should audit a successful login on an API endpoint', async () => {
await request(constants.BASE_URL)
.get('/channels')
.set('auth-username', testUtils.rootUser.email)
.set('auth-ts', authDetails.authTS)
.set('auth-salt', authDetails.authSalt)
.set('auth-token', authDetails.authToken)
.expect(200)
await testUtils.pollCondition(() => AuditModel.countDocuments().then(c => c === 1))
const audits = await AuditModel.find()
audits.length.should.be.exactly(1)
audits[0].eventIdentification.eventOutcomeIndicator.should.be.equal('0') // success
audits[0].eventIdentification.eventTypeCode.code.should.be.equal('110122')
audits[0].eventIdentification.eventTypeCode.displayName.should.be.equal('Login')
audits[0].activeParticipant.length.should.be.exactly(2)
audits[0].activeParticipant[0].userID.should.be.equal('OpenHIM')
audits[0].activeParticipant[1].userID.should.be.equal('root@jembi.org')
})
it('should audit a successful login on an API endpoint with basic auth', async () => {
await request(constants.BASE_URL)
.get('/channels')
.set('Authorization', `Basic ${Buffer.from(`${testUtils.rootUser.email}:password`).toString('base64')}`)
.expect(200)
await testUtils.pollCondition(() => AuditModel.countDocuments().then(c => c === 1))
const audits = await AuditModel.find()
audits.length.should.be.exactly(1)
audits[0].eventIdentification.eventOutcomeIndicator.should.be.equal('0') // success
audits[0].eventIdentification.eventTypeCode.code.should.be.equal('110122')
audits[0].eventIdentification.eventTypeCode.displayName.should.be.equal('Login')
audits[0].activeParticipant.length.should.be.exactly(2)
audits[0].activeParticipant[0].userID.should.be.equal('OpenHIM')
audits[0].activeParticipant[1].userID.should.be.equal('root@jembi.org')
})
it('should audit an unsuccessful login on an API endpoint', async () => {
await request(constants.BASE_URL)
.get('/channels')
.set('auth-username', 'wrong@email.org')
.set('auth-ts', authDetails.authTS)
.set('auth-salt', authDetails.authSalt)
.set('auth-token', authDetails.authToken)
.expect(401)
await testUtils.pollCondition(() => AuditModel.countDocuments().then(c => c === 1))
const audits = await AuditModel.find({})
audits.length.should.be.exactly(1)
audits[0].eventIdentification.eventOutcomeIndicator.should.be.equal('8') // failure
audits[0].eventIdentification.eventTypeCode.code.should.be.equal('110122')
audits[0].eventIdentification.eventTypeCode.displayName.should.be.equal('Login')
audits[0].activeParticipant.length.should.be.exactly(2)
audits[0].activeParticipant[0].userID.should.be.equal('OpenHIM')
audits[0].activeParticipant[1].userID.should.be.equal('wrong@email.org')
})
it('should audit an unsuccessful login on an API endpoint with basic auth and incorrect email', async () => {
await request(constants.BASE_URL)
.get('/channels')
.set('Authorization', `Basic ${Buffer.from(`wrong@email.org:password`).toString('base64')}`)
.expect(401)
await testUtils.pollCondition(() => AuditModel.countDocuments().then(c => c === 1))
const audits = await AuditModel.find({})
audits.length.should.be.exactly(1)
audits[0].eventIdentification.eventOutcomeIndicator.should.be.equal('8') // failure
audits[0].eventIdentification.eventTypeCode.code.should.be.equal('110122')
audits[0].eventIdentification.eventTypeCode.displayName.should.be.equal('Login')
audits[0].activeParticipant.length.should.be.exactly(2)
audits[0].activeParticipant[0].userID.should.be.equal('OpenHIM')
audits[0].activeParticipant[1].userID.should.be.equal('wrong@email.org')
})
it('should audit an unsuccessful login on an API endpoint with basic auth and incorrect password', async () => {
await request(constants.BASE_URL)
.get('/channels')
.set('Authorization', `Basic ${Buffer.from(`${testUtils.rootUser.email}:drowssap`).toString('base64')}`)
.expect(401)
await testUtils.pollCondition(() => AuditModel.countDocuments().then(c => c === 1))
const audits = await AuditModel.find({})
audits.length.should.be.exactly(1)
audits[0].eventIdentification.eventOutcomeIndicator.should.be.equal('8') // failure
audits[0].eventIdentification.eventTypeCode.code.should.be.equal('110122')
audits[0].eventIdentification.eventTypeCode.displayName.should.be.equal('Login')
audits[0].activeParticipant.length.should.be.exactly(2)
audits[0].activeParticipant[0].userID.should.be.equal('OpenHIM')
audits[0].activeParticipant[1].userID.should.be.equal('root@jembi.org')
})
it('should NOT audit a successful login on an auditing exempt API endpoint', async () => {
await request(constants.BASE_URL)
.get('/audits')
.set('auth-username', testUtils.rootUser.email)
.set('auth-ts', authDetails.authTS)
.set('auth-salt', authDetails.authSalt)
.set('auth-token', authDetails.authToken)
.expect(200)
const audits = await AuditModel.find({})
audits.length.should.be.exactly(0)
})
it('should audit an unsuccessful login on an auditing exempt API endpoint', async () => {
await request(constants.BASE_URL)
.get('/audits')
.set('auth-username', 'wrong@email.org')
.set('auth-ts', authDetails.authTS)
.set('auth-salt', authDetails.authSalt)
.set('auth-token', authDetails.authToken)
.expect(401)
await testUtils.pollCondition(() => AuditModel.countDocuments().then(c => c === 1))
const audits = await AuditModel.find({})
audits.length.should.be.exactly(1)
audits[0].eventIdentification.eventOutcomeIndicator.should.be.equal('8') // failure
audits[0].eventIdentification.eventTypeCode.code.should.be.equal('110122')
audits[0].eventIdentification.eventTypeCode.displayName.should.be.equal('Login')
audits[0].activeParticipant.length.should.be.exactly(2)
audits[0].activeParticipant[0].userID.should.be.equal('OpenHIM')
audits[0].activeParticipant[1].userID.should.be.equal('wrong@email.org')
})
it('should NOT audit a successful login on /transactions if the view is not full', async () => {
await request(constants.BASE_URL)
.get('/transactions') // default is simple
.set('auth-username', testUtils.rootUser.email)
.set('auth-ts', authDetails.authTS)
.set('auth-salt', authDetails.authSalt)
.set('auth-token', authDetails.authToken)
.expect(200)
const audits = await AuditModel.find({})
audits.length.should.be.exactly(0)
})
it('should audit a successful login on /transactions if the view is full', async () => {
await request(constants.BASE_URL)
.get('/transactions?filterRepresentation=full')
.set('auth-username', testUtils.rootUser.email)
.set('auth-ts', authDetails.authTS)
.set('auth-salt', authDetails.authSalt)
.set('auth-token', authDetails.authToken)
.expect(200)
const audits = await AuditModel.find()
audits.length.should.be.exactly(1)
audits[0].eventIdentification.eventOutcomeIndicator.should.be.equal('0') // success
audits[0].eventIdentification.eventTypeCode.code.should.be.equal('110122')
audits[0].eventIdentification.eventTypeCode.displayName.should.be.equal('Login')
audits[0].activeParticipant.length.should.be.exactly(2)
audits[0].activeParticipant[0].userID.should.be.equal('OpenHIM')
audits[0].activeParticipant[1].userID.should.be.equal('root@jembi.org')
})
})
describe('Authentication and authorisation tests', () => {
describe('Mutual TLS', () => {
let mockServer = null
before(async () => {
config.authentication.enableMutualTLSAuthentication = true
config.authentication.enableBasicAuthentication = false
// Setup some test data
await new ChannelModelAPI({
name: 'TEST DATA - Mock endpoint',
urlPattern: 'test/mock',
allow: ['PoC'],
routes: [{
name: 'test route',
host: 'localhost',
port: constants.MEDIATOR_PORT,
primary: true
}],
updatedBy: {
id: new ObjectId(),
name: 'Test'
}
}).save()
const testClientDoc1 = {
clientID: 'testApp',
clientDomain: 'test-client.jembi.org',
name: 'TEST Client',
roles: [
'OpenMRS_PoC',
'PoC'
],
passwordHash: '',
certFingerprint: '6D:BF:A5:BE:D7:F5:01:C2:EC:D0:BC:74:A4:12:5A:6F:36:C4:77:5C'
}
const testClientDoc2 = {
clientID: 'testApp2',
clientDomain: 'ca.openhim.org',
name: 'TEST Client 2',
roles: [
'OpenMRS_PoC',
'PoC'
],
passwordHash: '',
certFingerprint: '6B:0D:BD:02:BB:A4:40:29:89:51:6A:0A:A2:F4:BD:8B:F8:E8:47:84'
}
await ClientModelAPI.deleteMany({})
await new ClientModelAPI(testClientDoc1).save()
await new ClientModelAPI(testClientDoc2).save()
// remove default keystore
await KeystoreModelAPI.deleteMany({})
await new KeystoreModelAPI({
key: fs.readFileSync('test/resources/server-tls/key.pem'),
cert: {
data: fs.readFileSync('test/resources/server-tls/cert.pem'),
fingerprint: '23:37:6A:5E:A9:13:A4:8C:66:C5:BB:9F:0E:0D:68:9B:99:80:10:FC'
},
ca: [{
data: fs.readFileSync('test/resources/client-tls/cert.pem'),
fingerprint: '6D:BF:A5:BE:D7:F5:01:C2:EC:D0:BC:74:A4:12:5A:6F:36:C4:77:5C'
},
{
data: fs.readFileSync('test/resources/trust-tls/chain/intermediate.cert.pem'),
fingerprint: 'A9:C5:37:DF:84:FA:C8:BD:B8:5F:A3:9B:FF:52:D0:DB:79:9F:B1:3C'
},
{
data: fs.readFileSync('test/resources/trust-tls/chain/ca.cert.pem'),
fingerprint: '6B:0D:BD:02:BB:A4:40:29:89:51:6A:0A:A2:F4:BD:8B:F8:E8:47:84'
}
]
}).save()
mockServer = await testUtils.createMockHttpServer('Mock response body\n', constants.MEDIATOR_PORT, 201)
})
after(async () => {
await Promise.all([
ChannelModelAPI.deleteOne({ name: 'TEST DATA - Mock endpoint' }),
ClientModelAPI.deleteOne({ clientID: 'testApp' }),
ClientModelAPI.deleteOne({ clientID: 'testApp2' }),
mockServer.close()
])
})
afterEach(async () => {
await promisify(server.stop)()
})
it('should forward a request to the configured routes if the client is authenticated and authorised', async () => {
await promisify(server.start)({ httpPort: SERVER_PORTS.httpPort, httpsPort: SERVER_PORTS.httpsPort })
const options = {
host: 'localhost',
path: '/test/mock',
port: SERVER_PORTS.httpsPort,
cert: fs.readFileSync('test/resources/client-tls/cert.pem'),
key: fs.readFileSync('test/resources/client-tls/key.pem'),
ca: [fs.readFileSync('test/resources/server-tls/cert.pem')]
}
await new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
res.statusCode.should.be.exactly(201)
resolve()
})
req.on('error', reject)
req.end()
})
})
it('should reject a request when using an invalid cert', async () => {
await promisify(server.start)({ httpPort: SERVER_PORTS.httpPort, httpsPort: SERVER_PORTS.httpsPort })
const options = {
host: 'localhost',
path: '/test/mock',
port: SERVER_PORTS.httpsPort,
cert: fs.readFileSync('test/resources/client-tls/invalid-cert.pem'),
key: fs.readFileSync('test/resources/client-tls/invalid-key.pem'),
ca: [fs.readFileSync('test/resources/server-tls/cert.pem')]
}
await new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
res.statusCode.should.be.exactly(401)
resolve()
})
req.on('error', reject)
req.end()
})
})
it('should authenticate a client further up the chain if \'in-chain\' config is set', async () => {
config.tlsClientLookup.type = 'in-chain'
await promisify(server.start)({ httpPort: SERVER_PORTS.httpPort, httpsPort: SERVER_PORTS.httpsPort })
const options = {
host: 'localhost',
path: '/test/mock',
port: SERVER_PORTS.httpsPort,
cert: fs.readFileSync('test/resources/trust-tls/chain/test.openhim.org.cert.pem'),
key: fs.readFileSync('test/resources/trust-tls/chain/test.openhim.org.key.pem'),
ca: [fs.readFileSync('test/resources/server-tls/cert.pem')]
}
await new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
res.statusCode.should.be.exactly(201)
resolve()
})
req.on('error', reject)
req.end()
})
})
it('should reject a request with an invalid cert if \'in-chain\' config is set', async () => {
config.tlsClientLookup.type = 'in-chain'
await promisify(server.start)({ httpPort: SERVER_PORTS.httpPort, httpsPort: SERVER_PORTS.httpsPort })
const options = {
host: 'localhost',
path: '/test/mock',
port: SERVER_PORTS.httpsPort,
cert: fs.readFileSync('test/resources/client-tls/invalid-cert.pem'),
key: fs.readFileSync('test/resources/client-tls/invalid-key.pem'),
ca: [fs.readFileSync('test/resources/server-tls/cert.pem')]
}
await new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
res.statusCode.should.be.exactly(401)
resolve()
})
req.on('error', reject)
req.end()
})
})
it('should NOT authenticate a client further up the chain if \'strict\' config is set', async () => {
config.tlsClientLookup.type = 'strict'
await promisify(server.start)({ httpPort: SERVER_PORTS.httpPort, httpsPort: SERVER_PORTS.httpsPort })
const options = {
host: 'localhost',
path: '/test/mock',
port: SERVER_PORTS.httpsPort,
cert: fs.readFileSync('test/resources/trust-tls/chain/test.openhim.org.cert.pem'),
key: fs.readFileSync('test/resources/trust-tls/chain/test.openhim.org.key.pem'),
ca: [fs.readFileSync('test/resources/server-tls/cert.pem')]
}
await new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
res.statusCode.should.be.exactly(401)
resolve()
})
req.on('error', reject)
req.end()
})
})
})
describe('Basic Authentication', () => {
let mockServer = null
before(async () => {
config.authentication.enableMutualTLSAuthentication = false
config.authentication.enableBasicAuthentication = true
// Setup some test data
await new ChannelModelAPI({
name: 'TEST DATA - Mock endpoint',
urlPattern: 'test/mock',
allow: ['PoC'],
routes: [{
name: 'test route',
host: 'localhost',
port: constants.MEDIATOR_PORT,
primary: true
}],
updatedBy: {
id: new ObjectId(),
name: 'Test'
}
}).save()
const testAppDoc = {
clientID: 'testApp',
clientDomain: 'openhim.jembi.org',
name: 'TEST Client',
roles: [
'OpenMRS_PoC',
'PoC'
],
passwordAlgorithm: 'bcrypt',
passwordHash: '$2a$10$w8GyqInkl72LMIQNpMM/fenF6VsVukyya.c6fh/GRtrKq05C2.Zgy',
cert: ''
}
new ClientModelAPI(testAppDoc).save()
mockServer = await testUtils.createMockHttpServer('Mock response body 1\n', constants.MEDIATOR_PORT, 200)
})
after(async () => {
await Promise.all([
ChannelModelAPI.deleteOne({ name: 'TEST DATA - Mock endpoint' }),
ClientModelAPI.deleteOne({ clientID: 'testApp' }),
mockServer.close()
])
})
afterEach(async () => {
await promisify(server.stop)()
})
describe('with no credentials', () =>
it('should `throw` 401', async () => {
await promisify(server.start)({ httpPort: SERVER_PORTS.httpPort })
await request(constants.HTTP_BASE_URL)
.get('/test/mock')
.expect(401)
})
)
describe('with incorrect credentials', () =>
it('should `throw` 401', async () => {
await promisify(server.start)({ httpPort: SERVER_PORTS.httpPort })
await request(constants.HTTP_BASE_URL)
.get('/test/mock')
.auth('incorrect_user', 'incorrect_password')
.expect(401)
.expect('WWW-Authenticate', 'Basic')
})
)
describe('with correct credentials', () =>
it('should return 200 OK', async () => {
await promisify(server.start)({ httpPort: SERVER_PORTS.httpPort })
await request(constants.HTTP_BASE_URL)
.get('/test/mock')
.auth('testApp', 'password')
.expect(200)
})
)
})
})
})