openhim-core
Version:
The OpenHIM core application that provides logging and routing of http requests
702 lines (642 loc) • 22.9 kB
JavaScript
/* eslint-env mocha */
/* eslint no-unused-expressions:0 */
import sinon from 'sinon'
import moment from 'moment'
import * as alerts from '../../src/alerts'
import { ChannelModel, UserModel, ContactGroupModel, EventModel, AlertModel } from '../../src/model'
import { config } from '../../src/config'
import { promisify } from 'util'
import { dropTestDb } from '../utils'
import {ObjectId} from 'mongodb'
config.alerts = config.get('alerts')
const testUser1 = new UserModel({
firstname: 'User',
surname: 'One',
email: 'one@openhim.org',
passwordAlgorithm: 'sha512',
passwordHash: '3cc90918-7044-4e55-b61d-92ae73cb261e',
passwordSalt: '22a61686-66f6-483c-a524-185aac251fb0'
})
const testUser2 = new UserModel({
firstname: 'User',
surname: 'Two',
email: 'two@openhim.org',
msisdn: '27721234567',
passwordAlgorithm: 'sha512',
passwordHash: '3cc90918-7044-4e55-b61d-92ae73cb261e',
passwordSalt: '22a61686-66f6-483c-a524-185aac251fb0'
})
const testGroup1 = new ContactGroupModel({
_id: 'aaa908908bbb98cc1d0809ee',
group: 'group1',
users: [
{
user: 'one@openhim.org',
method: 'email'
},
{
user: 'two@openhim.org',
method: 'email',
maxAlerts: '1 per day'
}
]
})
const testGroup2 = new ContactGroupModel({
_id: 'bbb908908ccc98cc1d0888aa',
group: 'group2',
users: [{ user: 'one@openhim.org', method: 'email' }]
})
const testFailureRate = 50
const testChannel = new ChannelModel({
name: 'test',
urlPattern: '/test',
allow: '*',
alerts: [
{
condition: 'status',
status: '404',
groups: ['aaa908908bbb98cc1d0809ee']
},
{
condition: 'status',
status: '5xx',
groups: ['bbb908908ccc98cc1d0888aa'],
users: [{ user: 'two@openhim.org', method: 'sms' }],
failureRate: testFailureRate
}
],
updatedBy: {
id: new ObjectId(),
name: 'Test'
}
})
const disabledChannel = new ChannelModel({
name: 'disabled',
urlPattern: '/disabled',
allow: '*',
alerts: [
{
condition: 'status',
status: '404',
groups: ['aaa908908bbb98cc1d0809ee']
}
],
status: 'disabled',
updatedBy: {
id: new ObjectId(),
name: 'Test'
}
})
const autoRetryChannel = new ChannelModel({
name: 'autoretry',
urlPattern: '/autoretry',
allow: '*',
autoRetryEnabled: true,
autoRetryPeriodMinutes: 1,
autoRetryMaxAttempts: 3,
alerts: [
{
condition: 'auto-retry-max-attempted',
groups: ['aaa908908bbb98cc1d0809ee']
}
],
updatedBy: {
id: new ObjectId(),
name: 'Test'
}
})
const testTransactions = [
// 0
new EventModel({
transactionID: 'aaa908908bbb98cc1daaaaa0',
event: 'end',
status: 404,
type: 'channel'
}),
// 1
new EventModel({
transactionID: 'aaa908908bbb98cc1daaaaa1',
event: 'end',
status: 404,
type: 'channel'
}),
// 2
new EventModel({
transactionID: 'aaa908908bbb98cc1daaaaa2',
event: 'end',
status: 400,
type: 'channel'
}),
// 3
new EventModel({
transactionID: 'aaa908908bbb98cc1daaaaa3',
event: 'end',
status: 500,
type: 'channel'
}),
// 4
new EventModel({
transactionID: 'aaa908908bbb98cc1daaaaa4',
event: 'end',
status: 500,
type: 'channel'
}),
// 5
new EventModel({
transactionID: 'aaa908908bbb98cc1daaaaa5',
event: 'end',
status: 500,
type: 'channel'
}),
// 6
new EventModel({
transactionID: 'aaa908908bbb98cc1daaaaa6',
event: 'end',
status: 404,
type: 'channel'
}),
// 7
new EventModel({
transactionID: 'aaa908908bbb98cc1daaaaa7',
event: 'end',
status: 404,
type: 'channel'
}),
// 8
new EventModel({
transactionID: 'aaa908908bbb98cc1daaaaa8',
event: 'end',
status: 500,
autoRetryAttempt: 2,
type: 'channel'
}),
// 9
new EventModel({
transactionID: 'aaa908908bbb98cc1daaaaa9',
event: 'end',
status: 500,
autoRetryAttempt: 3,
type: 'channel'
}),
// 10 - channel event for 9
new EventModel({
transactionID: 'aaa908908bbb98cc1daaaaa9',
event: 'end',
status: 500,
autoRetryAttempt: 3,
type: 'channel'
}),
// 11
new EventModel({
transactionID: 'aaa908908bbb98cc1daaaaa9',
event: 'end',
status: 200,
autoRetryAttempt: 3,
type: 'channel'
})
]
const dateFrom = new Date()
dateFrom.setHours(0, 0, 0, 0)
describe('Transaction Alerts', () => {
before(async () => {
await Promise.all([
testUser1.save(),
testUser2.save()
])
await Promise.all([
testGroup1.save(),
testGroup2.save()
])
await Promise.all([
testChannel.save(),
disabledChannel.save(),
autoRetryChannel.save()
])
testTransactions.forEach(tt => {
tt.channelID = testChannel._id
})
testTransactions[6].channelID = '000000000000000000000000' // a channel id that doesn't exist
testTransactions[7].channelID = disabledChannel._id
testTransactions[8].channelID = autoRetryChannel._id
testTransactions[9].channelID = autoRetryChannel._id
testTransactions[10].channelID = autoRetryChannel._id
testTransactions[11].channelID = autoRetryChannel._id
})
after(async () => {
await dropTestDb()
})
afterEach(async () => {
await Promise.all([
AlertModel.deleteMany({}),
EventModel.deleteMany({})
])
for (const testTransaction of testTransactions) {
testTransaction.isNew = true
delete testTransaction._id
}
})
describe('config', () =>
it('default config should contain alerting config fields', () => {
config.alerts.should.exist
config.alerts.enableAlerts.should.exist
config.alerts.pollPeriodMinutes.should.exist
config.alerts.himInstance.should.exist
config.alerts.consoleURL.should.exist
})
)
describe('.findTransactionsMatchingStatus', () => {
it('should return transactions that match an exact status', async () => {
await testTransactions[0].save()
const results = await promisify(alerts.findTransactionsMatchingStatus)(testChannel, {
condition: 'status',
status: '404'
}, dateFrom)
results.length.should.be.exactly(1)
results[0]._id.equals(testTransactions[0]._id).should.be.true()
})
it('should return transactions that have a matching status in a route response', done => {
testTransactions[1].save((err) => {
if (err) { return done(err) }
alerts.findTransactionsMatchingStatus(testChannel, {
condition: 'status',
status: '404'
}, dateFrom, (err, results) => {
if (err) { return done(err) }
results.length.should.be.exactly(1)
results[0]._id.equals(testTransactions[1]._id).should.be.true()
return done()
})
})
})
it('should only return transactions for the requested channel', done => {
// should return transaction 0 but not 6
testTransactions[0].save((err) => {
if (err) { return done(err) }
testTransactions[6].save((err) => {
if (err) { return done(err) }
alerts.findTransactionsMatchingStatus(testChannel, {
condition: 'status',
status: '404'
}, dateFrom, (err, results) => {
if (err) { return done(err) }
results.length.should.be.exactly(1)
results[0]._id.equals(testTransactions[0]._id).should.be.true()
return done()
})
})
})
})
it('should not return transactions that occur before dateFrom', done => {
testTransactions[0].save((err) => {
if (err) { return done(err) }
const newFrom = moment().add(1, 'days').toDate()
alerts.findTransactionsMatchingStatus(testChannel, {
condition: 'status',
status: '404'
}, newFrom, (err, results) => {
if (err) { return done(err) }
results.length.should.be.exactly(0)
return done()
})
})
})
it('should return all matching transactions for a fuzzy status search for the specified channel', done => {
// should return transactions 0, 1 and 2 but not 3 or 6
testTransactions[0].save((err) => {
if (err) { return done(err) }
testTransactions[1].save((err) => {
if (err) { return done(err) }
testTransactions[2].save((err) => {
if (err) { return done(err) }
testTransactions[3].save((err) => {
if (err) { return done(err) }
testTransactions[6].save((err) => {
if (err) { return done(err) }
alerts.findTransactionsMatchingStatus(testChannel, {
condition: 'status',
status: '4xx'
}, dateFrom, (err, results) => {
if (err) { return done(err) }
results.length.should.be.exactly(3)
const resultIDs = results.map(result => result._id)
resultIDs.should.containEql(testTransactions[0]._id)
resultIDs.should.containEql(testTransactions[1]._id)
resultIDs.should.containEql(testTransactions[2]._id)
resultIDs.should.not.containEql(testTransactions[6]._id)
return done()
})
})
})
})
})
})
})
it('should not return any transactions when their count is below the failure rate', done => {
testTransactions[0].save((err) => {
if (err) { return done(err) }
testTransactions[1].save((err) => {
if (err) { return done(err) }
testTransactions[3].save((err) => {
if (err) { return done(err) }
alerts.findTransactionsMatchingStatus(testChannel, {
condition: 'status',
status: '500',
failureRate: testFailureRate
}, dateFrom, (err, results) => {
if (err) { return done(err) }
// only one 500 transaction, but failureRate is 50%
results.length.should.be.exactly(0)
return done()
})
})
})
})
})
it('should return transactions when their count is equal to the failure rate', done => {
testTransactions[0].save((err) => {
if (err) { return done(err) }
testTransactions[1].save((err) => {
if (err) { return done(err) }
testTransactions[3].save((err) => {
if (err) { return done(err) }
testTransactions[4].save((err) => {
if (err) { return done(err) }
alerts.findTransactionsMatchingStatus(testChannel, {
condition: 'status',
status: '500',
failureRate: testFailureRate
}, dateFrom, (err, results) => {
if (err) { return done(err) }
results.length.should.be.exactly(2)
const resultIDs = results.map(result => result._id)
resultIDs.should.containEql(testTransactions[3]._id)
resultIDs.should.containEql(testTransactions[4]._id)
return done()
})
})
})
})
})
})
it('should return transactions when their count is above the failure rate', done => {
testTransactions[0].save((err) => {
if (err) { return done(err) }
testTransactions[1].save((err) => {
if (err) { return done(err) }
testTransactions[3].save((err) => {
if (err) { return done(err) }
testTransactions[4].save((err) => {
if (err) { return done(err) }
testTransactions[5].save((err) => {
if (err) { return done(err) }
alerts.findTransactionsMatchingStatus(testChannel, {
condition: 'status',
status: '500',
failureRate: testFailureRate
}, dateFrom, (err, results) => {
if (err) { return done(err) }
results.length.should.be.exactly(3)
const resultIDs = results.map(result => result._id)
resultIDs.should.containEql(testTransactions[3]._id)
resultIDs.should.containEql(testTransactions[4]._id)
resultIDs.should.containEql(testTransactions[5]._id)
return done()
})
})
})
})
})
})
})
it('should not return any transactions when the count is equal/above the failure rate, but an alert has already been sent', (done) => {
const alert = new AlertModel({
user: 'one@openhim.org',
method: 'email',
channelID: testChannel._id,
condition: 'status',
status: '500',
alertStatus: 'Completed'
})
alert.save(err => {
if (err) { return done(err) }
testTransactions[0].save((err) => {
if (err) { return done(err) }
testTransactions[1].save((err) => {
if (err) { return done(err) }
testTransactions[3].save((err) => {
if (err) { return done(err) }
testTransactions[4].save((err) => {
if (err) { return done(err) }
alerts.findTransactionsMatchingStatus(testChannel, {
condition: 'status',
status: '500',
failureRate: testFailureRate
}, dateFrom, (err, results) => {
if (err) { return done(err) }
results.length.should.be.exactly(0)
return done()
})
})
})
})
})
})
})
})
describe('.findTransactionsMaxRetried', () => {
it('should not return transactions have not reached max retries', done => {
testTransactions[8].save((err) => {
if (err) { return done(err) }
alerts.findTransactionsMaxRetried(autoRetryChannel, autoRetryChannel.alerts[0], dateFrom,
(err, results) => {
if (err) { return done(err) }
results.length.should.be.exactly(0)
return done()
})
})
})
it('should return transactions have reached max retries', done => {
testTransactions[9].save((err) => {
if (err) { return done(err) }
alerts.findTransactionsMaxRetried(autoRetryChannel, autoRetryChannel.alerts[0], dateFrom,
(err, results) => {
if (err) { return done(err) }
results.length.should.be.exactly(1)
results[0]._id.equals(testTransactions[9]._id).should.be.true()
return done()
})
})
})
it('should not return successful transactions that have reached max retries', done => {
testTransactions[11].save((err) => {
if (err) { return done(err) }
alerts.findTransactionsMaxRetried(autoRetryChannel, autoRetryChannel.alerts[0], dateFrom,
(err, results) => {
if (err) { return done(err) }
results.length.should.be.exactly(0)
return done()
})
})
})
it('should not return duplicate transaction IDs where multiple events exist for the same transaction', done => {
testTransactions[9].save((err) => {
if (err) { return done(err) }
testTransactions[10].save((err) => {
if (err) { return done(err) }
alerts.findTransactionsMaxRetried(autoRetryChannel, autoRetryChannel.alerts[0], dateFrom,
(err, results) => {
if (err) { return done(err) }
results.length.should.be.exactly(1)
results[0].transactionID.equals(testTransactions[9].transactionID).should.be.true()
return done()
})
})
})
})
})
describe('.alertingTask', () => {
const buildJobStub = function (date) {
const jobStub = {}
jobStub.attrs = {}
if (date) {
jobStub.attrs.data = {}
jobStub.attrs.data.lastAlertDate = date
}
return jobStub
}
const mockContactHandler = function (spy, err) {
if (err == null) { err = null }
return function (method, contactAddress, title, messagePlain, messageHTML, callback) {
spy(method, contactAddress, title, messagePlain, messageHTML)
return callback(err)
}
}
it('should not contact users if there no matching transactions', (done) => {
const contactSpy = sinon.spy()
alerts.alertingTask(buildJobStub(null), mockContactHandler(contactSpy), () => {
contactSpy.called.should.be.false
return done()
})
})
it('should set the last run date as a job attribute', (done) => {
const jobStub = buildJobStub(null)
const contactSpy = sinon.spy()
alerts.alertingTask(jobStub, mockContactHandler(contactSpy), () => {
jobStub.attrs.data.should.exist
jobStub.attrs.data.lastAlertDate.should.exist
jobStub.attrs.data.lastAlertDate.should.be.instanceof(Date)
return done()
})
})
it('should contact users when there are matching transactions', (done) => {
const contactSpy = sinon.spy()
testTransactions[0].save((err) => {
if (err) { return done(err) }
alerts.alertingTask(buildJobStub(dateFrom), mockContactHandler(contactSpy), () => {
contactSpy.calledTwice.should.be.true()
contactSpy.withArgs('email', 'one@openhim.org', 'OpenHIM Alert', sinon.match.string, sinon.match.string).calledOnce.should.be.true()
contactSpy.withArgs('email', 'two@openhim.org', 'OpenHIM Alert', sinon.match.string, sinon.match.string).calledOnce.should.be.true()
return done()
})
})
})
it('should store an alert log item in mongo for each alert generated', (done) => {
const contactSpy = sinon.spy()
testTransactions[0].save((err) => {
if (err) { return done(err) }
alerts.alertingTask(buildJobStub(dateFrom), mockContactHandler(contactSpy), () => {
contactSpy.called.should.be.true()
AlertModel.find({}, (err, results) => {
if (err) { return done(err) }
results.length.should.be.exactly(2)
const resultUsers = results.map(result => result.user)
resultUsers.should.containEql(testUser1.email)
resultUsers.should.containEql(testUser2.email)
return done()
})
})
})
})
it('should contact users using their specified method', (done) => {
const contactSpy = sinon.spy()
testTransactions[3].save((err) => {
if (err) { return done(err) }
testTransactions[4].save((err) => {
if (err) { return done(err) }
alerts.alertingTask(buildJobStub(dateFrom), mockContactHandler(contactSpy), () => {
contactSpy.calledTwice.should.be.true()
contactSpy.withArgs('email', testUser1.email, 'OpenHIM Alert', sinon.match.string, sinon.match.string).calledOnce.should.be.true()
contactSpy.withArgs('sms', testUser2.msisdn, 'OpenHIM Alert', sinon.match.string, null).calledOnce.should.be.true()
return done()
})
})
})
})
it('should not send alerts to users with a maxAlerts restriction if they\'ve already received an alert for the same day', (done) => {
const contactSpy = sinon.spy()
testTransactions[0].save((err) => {
if (err) { return done(err) }
alerts.alertingTask(buildJobStub(dateFrom), mockContactHandler(contactSpy), () => {
contactSpy.calledTwice.should.be.true()
const secondSpy = sinon.spy()
alerts.alertingTask(buildJobStub(dateFrom), mockContactHandler(secondSpy), () => {
secondSpy.calledOnce.should.be.true()
secondSpy.withArgs('email', testUser1.email, 'OpenHIM Alert', sinon.match.string, sinon.match.string).calledOnce.should.be.true()
return done()
})
})
})
})
it('should send alerts to users if an alert for the same day was already attempted but it failed', (done) => {
const contactSpy = sinon.spy()
testTransactions[0].save((err) => {
if (err) { return done(err) }
alerts.alertingTask(buildJobStub(dateFrom), mockContactHandler(contactSpy, 'Test Failure'), () => {
contactSpy.calledTwice.should.be.true()
const secondSpy = sinon.spy()
alerts.alertingTask(buildJobStub(dateFrom), mockContactHandler(secondSpy), () => {
secondSpy.calledTwice.should.be.true()
secondSpy.withArgs('email', 'one@openhim.org', 'OpenHIM Alert', sinon.match.string, sinon.match.string).calledOnce.should.be.true()
secondSpy.withArgs('email', 'two@openhim.org', 'OpenHIM Alert', sinon.match.string, sinon.match.string).calledOnce.should.be.true()
return done()
})
})
})
})
it('should not generate alerts for disabled channels', (done) => {
const contactSpy = sinon.spy()
testTransactions[0].save((err) => {
if (err) { return done(err) }
testTransactions[7].save((err) => {
if (err) { return done(err) }
alerts.alertingTask(buildJobStub(dateFrom), mockContactHandler(contactSpy), () => {
contactSpy.called.should.be.true()
AlertModel.find({}, (err, results) => {
if (err) { return done(err) }
results.length.should.be.exactly(2)
const resultUsers = results.map(result => result.user)
resultUsers.should.containEql(testUser1.email)
resultUsers.should.containEql(testUser2.email)
const resultChannels = results.map(result => result.channelID)
resultChannels.should.containEql(testChannel._id.toHexString())
resultChannels.should.not.containEql(disabledChannel._id.toHexString())
return done()
})
})
})
})
})
it('should contact users when there are matching max auto retried transactions', (done) => {
const contactSpy = sinon.spy()
testTransactions[9].save((err) => {
if (err) { return done(err) }
alerts.alertingTask(buildJobStub(dateFrom), mockContactHandler(contactSpy), () => {
contactSpy.calledTwice.should.be.true()
contactSpy.withArgs('email', 'one@openhim.org', 'OpenHIM Alert', sinon.match.string, sinon.match.string).calledOnce.should.be.true()
contactSpy.withArgs('email', 'two@openhim.org', 'OpenHIM Alert', sinon.match.string, sinon.match.string).calledOnce.should.be.true()
return done()
})
})
})
})
})