diff --git a/.travis.yml b/.travis.yml index a5e5dfa..14cbac1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ node_js: - lts/* before_script: - - cp test/config.json config.json + - cp test/config.json lib/config/config.json script: - npm run lint diff --git a/lib/outgoing/index.js b/lib/outgoing/index.js index 6f72eb6..38cd535 100644 --- a/lib/outgoing/index.js +++ b/lib/outgoing/index.js @@ -9,10 +9,11 @@ * @license AGPL-3.0+ */ -module.exports = (logger, config, xmpp, user, destination, message, sendToGroup, code) => { +module.exports = (logger, config, xmpp, user, destination, message, sendToGroup, code, callback = () => {}) => { let webhook = config.getOutgoingWebhook(code) if (!webhook) { logger.warn(`There is no webhook with code "${code}"`) + callback(new Error(`There is no webhook with code "${code}"`), null, null) return } const request = require('request') @@ -71,14 +72,20 @@ module.exports = (logger, config, xmpp, user, destination, message, sendToGroup, logger.debug('statusCode:', response && response.statusCode) if (error) { logger.error('HTTP error:', error) + callback(new Error('HTTP error:', error), null, null) return } if (response.statusCode === 200) { logger.trace('Response:', body) - if (body && 'reply' in body === true) { + if (body && typeof (body) === 'object' && 'reply' in body === true) { logger.debug(`There is a reply to send back in chat ${destination}: ${body.reply}`) xmpp.send(destination, body.reply, sendToGroup) + callback(null, `Message sent. There is a reply to send back in chat ${destination}: ${body.reply}`, null) + return } + callback(null, 'Message sent', null) + return } + callback(new Error('HTTP error:', response.statusCode), null, null) }) } diff --git a/lib/server.js b/lib/server.js index ae11197..d00188a 100644 --- a/lib/server.js +++ b/lib/server.js @@ -13,11 +13,7 @@ logger.updateConfig(config.logger) const xmpp = require('./xmpp')(logger, config) // load webhook module -const webhookListener = require('./webhook')(logger, config, xmpp) +require('./webhook')(logger, config, xmpp) // handle error and process ending require('./error')(logger, xmpp) - -exports.close = () => { - webhookListener.close() -} diff --git a/package-lock.json b/package-lock.json index 718b061..0edf747 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3919,6 +3919,37 @@ } } }, + "nock": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/nock/-/nock-11.4.0.tgz", + "integrity": "sha512-UrVEbEAvhyDoUttrS0fv3znhZ5nEJvlxqgmrC6Gb2Mf9cFci65RMK17e6EjDDQB57g5iwZw1TFnVvyeL0eUlhQ==", + "dev": true, + "requires": { + "chai": "^4.1.2", + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.13", + "mkdirp": "^0.5.0", + "propagate": "^2.0.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, "node-cleanup": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz", @@ -4544,6 +4575,12 @@ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true }, + "propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true + }, "proxy-addr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.5.tgz", diff --git a/package.json b/package.json index dd4b84f..6d34bc9 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "eslint-plugin-standard": "^4.0.1", "mocha": "^6.2.1", "mock-require": "^3.0.3", + "nock": "^11.4.0", "nodemon": "^1.19.3", "nyc": "^14.1.1", "sinon": "^7.5.0" diff --git a/test/config.json b/test/config.json index 29987d7..eb86d8b 100644 --- a/test/config.json +++ b/test/config.json @@ -86,13 +86,43 @@ "outgoingWebhooks": [ { "code": "w1", - "url": "https://domain.ltd:port/path/resource?parameter1=value1", + "url": "https://domain.ltd:port/path/resource", "strictSSL": true, "contentType": "application/json", "authMethod": "basic", "user": "user3", "password": "3pass", "bearer": null + }, + { + "code": "w2", + "url": "https://domain.ltd:port/path/resource", + "strictSSL": true, + "contentType": "application/x-www-form-urlencoded", + "authMethod": "bearer", + "user": null, + "password": null, + "bearer": "abcdefgh" + }, + { + "code": "w3", + "url": "https://domain.ltd:port/path/protectedresource", + "strictSSL": true, + "contentType": "application/json", + "authMethod": null, + "user": null, + "password": null, + "bearer": null + }, + { + "code": "w4", + "url": "https://domain.ltd:port/path/errorresource", + "strictSSL": true, + "contentType": "application/json", + "authMethod": null, + "user": null, + "password": null, + "bearer": null } ] } diff --git a/test/outgoing.js b/test/outgoing.js new file mode 100644 index 0000000..0c2c57a --- /dev/null +++ b/test/outgoing.js @@ -0,0 +1,121 @@ +'use strict' +/* eslint-disable handle-callback-err */ +process.env.NODE_ENV = 'production' + +const sinon = require('sinon') +const should = require('chai').should() +const nock = require('nock') +const Outgoing = require('./../lib/outgoing') +require('chai').should() + +describe('Outgoing webhook component', () => { + let logger, config, xmppSendStub, xmpp, scope, scopeUnauthorized, scopeWithError, reqSpy + + before('Setup', () => { + // create default logger + logger = require('./../lib/logger')() + + // get configuration + config = require('./../lib/config')(logger, './test/config.json') + + // update logger with configuration + logger.updateConfig(config.logger) + + // mock xmpp component + xmppSendStub = sinon.stub() + xmpp = { + send: xmppSendStub + } + + // spy nock requests + reqSpy = sinon.spy() + }) + + beforeEach('Reset XMPP stub history', function (done) { + xmppSendStub.resetHistory() + reqSpy.resetHistory() + + // mock remote server + scope = nock('https://domain.ltd:port') + .post('/path/resource') + .reply(200, { reply: 'This is a reply' }) + scope.on('request', reqSpy) + scopeUnauthorized = nock('https://domain.ltd:port') + .post('/path/protectedresource') + .reply(401, {}) + scopeUnauthorized.on('request', reqSpy) + scopeWithError = nock('https://domain.ltd:port') + .post('/path/errorresource') + .replyWithError('') + scopeWithError.on('request', reqSpy) + + done() + }) + + describe('Unkwnow outgoing webhook', () => { + it('Should not execute request', (done) => { + Outgoing(logger, config, xmpp, 'user', 'destination', 'message', true, 'code', (error, response, body) => { + should.not.equal(error, null) + sinon.assert.notCalled(reqSpy) + done() + }) + }) + }) + + describe('POST with basic authorization and JSON content-type and reply message to XMPP', () => { + it('Should send basic authentication and JSON content-type in header and send an XMPP message', (done) => { + Outgoing(logger, config, xmpp, 'user', 'destination', 'This a first message', true, 'w1', (error, response, body) => { + should.equal(error, null) + sinon.assert.calledOnce(reqSpy) + const req = reqSpy.args[0][0] + const bodyReq = JSON.parse(reqSpy.args[0][2]) + req.headers.authorization.should.equal('Basic dXNlcjM6M3Bhc3M=') + req.headers['content-type'].should.equal('application/json') + bodyReq.from.should.equal('user') + bodyReq.channel.should.equal('destination') + bodyReq.message.should.equal('This a first message') + sinon.assert.calledOnce(xmppSendStub) + const xmppSendArgs = xmppSendStub.args[0] + xmppSendArgs[0].should.equal('destination') + xmppSendArgs[1].should.equal('This is a reply') + xmppSendArgs[2].should.equal(true) + done() + }) + }) + }) + + describe('POST with bearer authorization and JSON content-type', () => { + it('Should send basic authentication in header', (done) => { + Outgoing(logger, config, xmpp, 'user', 'destination', 'This a second message', true, 'w2', (error, response, body) => { + should.equal(error, null) + sinon.assert.calledOnce(reqSpy) + const req = reqSpy.args[0][0] + const bodyReq = decodeURIComponent(reqSpy.args[0][2]) + req.headers.authorization.should.equal('Bearer abcdefgh') + req.headers['content-type'].should.equal('application/x-www-form-urlencoded') + bodyReq.should.equal('from=user&message=This a second message&channel=destination') + done() + }) + }) + }) + + describe('POST without authorization', () => { + it('Should not send authorization in header and handle 401', (done) => { + Outgoing(logger, config, xmpp, 'user', 'destination', 'This a second message', true, 'w3', (error, response, body) => { + should.not.equal(error, null) + sinon.assert.calledOnce(reqSpy) + done() + }) + }) + }) + + describe('POST with HTTP error', () => { + it('Should handle error', (done) => { + Outgoing(logger, config, xmpp, 'user', 'destination', 'This a second message', true, 'w4', (error, response, body) => { + should.not.equal(error, null) + sinon.assert.calledOnce(reqSpy) + done() + }) + }) + }) +}) diff --git a/test/server.js b/test/server.js new file mode 100644 index 0000000..77493ee --- /dev/null +++ b/test/server.js @@ -0,0 +1,44 @@ +'use strict' +process.env.NODE_ENV = 'production' +require('chai').should() +const mock = require('mock-require') +const sinon = require('sinon') + +describe('Server', () => { + let xmppStub, webhookStub + + before('Setup', (done) => { + // mock XMPP component + xmppStub = sinon.stub() + webhookStub = sinon.stub() + mock('./../lib/xmpp', () => { + let xmpp = {} + xmppStub() + return xmpp + }) + + // mock webhook component + mock('./../lib/webhook', webhookStub) + + done() + }) + + after('Remove mock', () => { + mock.stopAll() + }) + + beforeEach('Reset stub', (done) => { + xmppStub.resetHistory() + webhookStub.resetHistory() + done() + }) + + describe('Start server', () => { + it('Should call XMPP and webhook components', (done) => { + require('../lib/server') + sinon.assert.calledOnce(xmppStub) + sinon.assert.calledOnce(webhookStub) + done() + }) + }) +}) diff --git a/test/tests.js b/test/tests.js deleted file mode 100644 index 07310cb..0000000 --- a/test/tests.js +++ /dev/null @@ -1,91 +0,0 @@ -/* eslint-disable handle-callback-err */ -/* eslint-disable no-undef */ -process.env.NODE_ENV = 'production' -// eslint-disable-next-line no-unused-vars -const should = require('chai').should() -let request = require('request') -let server = require('../lib/server') -const baseUrl = 'http://localhost:8000/' - -after((done) => { - server.close() - done() -}) - -describe('Webhook', () => { - options = { - method: 'POST', - url: baseUrl + 'webhooks/', - auth: { - user: 'login1', - pass: '1pass' - } - } - describe('POST unauthorized', () => { - it('Should return 401', (done) => { - request.post(baseUrl, (error, response, body) => { - response.statusCode.should.equal(401) - done() - }) - }) - }) - describe('GET', () => { - it('Should return 405', (done) => { - request.get(baseUrl, { auth: options.auth }, (error, response, body) => { - response.statusCode.should.equal(405) - done() - }) - }) - }) - describe('POST unknown webhook', () => { - it('Should return 404', (done) => { - request(options, (error, response, body) => { - response.statusCode.should.equal(404) - done() - }) - }) - }) - describe('POST dummy webhook', () => { - it('Should return 204', (done) => { - options.url = baseUrl + 'webhooks/dummy' - request(options, (error, response, body) => { - response.statusCode.should.equal(204) - done() - }) - }) - }) - describe('POST missing destination webhook', () => { - it('Should return 400 and error detail', (done) => { - options.url = baseUrl + 'webhooks/w1' - request(options, (error, response, body) => { - response.statusCode.should.equal(400) - response.body.should.equal('Destination not found') - done() - }) - }) - }) - describe('POST missing message webhook', () => { - it('Should return 400 and error detail', (done) => { - options.json = { - destination: 'destination' - } - request(options, (error, response, body) => { - response.statusCode.should.equal(400) - response.body.should.equal('Message not found') - done() - }) - }) - }) - describe('POST valid webhook', () => { - it('Should return 200', (done) => { - options.json = { - destination: 'destination', - message: 'message' - } - request(options, (error, response, body) => { - response.statusCode.should.equal(200) - done() - }) - }) - }) -}) diff --git a/test/webhook.js b/test/webhook.js new file mode 100644 index 0000000..194c342 --- /dev/null +++ b/test/webhook.js @@ -0,0 +1,173 @@ +'use strict' +/* eslint-disable handle-callback-err */ +process.env.NODE_ENV = 'production' + +require('chai').should() +const sinon = require('sinon') +const fs = require('fs') +const request = require('request') + +describe('Webhook component', () => { + let logger, config, xmppSendStub, xmpp, baseUrl, webhook, logFile, options + + before('Setup', () => { + // create default logger + logger = require('./../lib/logger')() + + // get configuration + config = require('./../lib/config')(logger, './test/config.json') + + // update logger with configuration + logger.updateConfig(config.logger) + + // mock xmpp component + xmppSendStub = sinon.stub() + xmpp = { + send: xmppSendStub + } + + // configure webhook + baseUrl = 'http://localhost:' + config.listener.port + webhook = require('./../lib/webhook')(logger, config, xmpp) + logFile = config.listener.log.path + config.listener.log.filename + }) + + beforeEach('Reset XMPP stub history and request option', function () { + xmppSendStub.resetHistory() + options = { + method: 'POST', + url: baseUrl + config.listener.path + '/', + auth: { + user: 'login1', + pass: '1pass' + } + } + }) + + after('Close listener', (done) => { + webhook.close() + done() + }) + + describe('POST without authorization', () => { + it('Should return 401 and be logged', (done) => { + request.post(baseUrl, (error, response, body) => { + response.statusCode.should.equal(401) + fs.readFile(logFile, 'utf8', (err, data) => { + if (err) { + throw err + } + data.should.match(new RegExp('"POST / HTTP/1.1" 401 ')) + done() + }) + }) + }) + }) + + describe('Wrong method (GET)', () => { + it('Should return 405', (done) => { + request.get(baseUrl, { auth: options.auth }, (error, response, body) => { + response.statusCode.should.equal(405) + done() + }) + }) + }) + + describe('POST unknown webhook', () => { + it('Should return 404 and be logged', (done) => { + options.url += 'unknown' + request(options, (error, response, body) => { + response.statusCode.should.equal(404) + fs.readFile(logFile, 'utf8', (err, data) => { + if (err) { + throw err + } + data.should.match(new RegExp('"POST ' + config.listener.path + '/unknown HTTP/1.1" 404 ')) + done() + }) + }) + }) + }) + + describe('POST dummy webhook', () => { + it('Should return 204', (done) => { + options.url += 'dummy' + request(options, (error, response, body) => { + response.statusCode.should.equal(204) + done() + }) + }) + }) + + describe('POST missing destination webhook', () => { + it('Should return 400 and error detail', (done) => { + options.url += 'w1' + request(options, (error, response, body) => { + response.statusCode.should.equal(400) + response.body.should.equal('Destination not found') + done() + }) + }) + }) + + describe('POST missing message webhook', () => { + it('Should return 400 and error detail', (done) => { + options.json = { + destination: 'destination' + } + options.url += 'w1' + request(options, (error, response, body) => { + response.statusCode.should.equal(400) + response.body.should.equal('Message not found') + done() + }) + }) + }) + + describe('POST valid webhook (send message)', () => { + it('Should return 200 and send XMPP message', (done) => { + options.json = { + destination: 'destination', + message: 'This is a message' + } + options.url += 'w1' + request(options, (error, response, body) => { + response.statusCode.should.equal(200) + sinon.assert.calledOnce(xmppSendStub) + const args = xmppSendStub.args[0] + args.should.have.length(3) + args[0].should.equal(options.json.destination) + args[1].should.equal(options.json.message) + args[2].should.equal(false) + done() + }) + }) + }) + + describe('POST valid webhook (send template)', () => { + it('Should return 200 and send XMPP message', (done) => { + options.json = { + title: 'This is a title', + message: 'This is a message', + evalMatches: [ + { + metric: 'metric', + value: 'value' + } + ], + imageUrl: 'https://domain.ltd:port/path/image' + } + options.url += 'grafana' + request(options, (error, response, body) => { + response.statusCode.should.equal(200) + sinon.assert.calledOnce(xmppSendStub) + const args = xmppSendStub.args[0] + args.should.have.length(3) + args[0].should.equal('grafana@conference.domain-xmpp.ltd') + args[1].should.equal('This is a title\r\nThis is a message\r\nmetric: value\r\nhttps://domain.ltd:port/path/image') + args[2].should.equal(true) + done() + }) + }) + }) +}) diff --git a/test/xmpp.js b/test/xmpp.js index 7c264ac..2600ed6 100644 --- a/test/xmpp.js +++ b/test/xmpp.js @@ -1,3 +1,4 @@ +'use strict' process.env.NODE_ENV = 'production' const should = require('chai').should() @@ -5,32 +6,43 @@ const sinon = require('sinon') const EventEmitter = require('events').EventEmitter const mock = require('mock-require') -// create default logger -let logger = require('./../lib/logger')() - -// get configuration -let config = require('./../lib/config')(logger, './test/config.json') - -// update logger with configuration -logger.updateConfig(config.logger) - -// mock simple-xmpp module -const simpleXmppEvents = new EventEmitter() -let xmppJoinStub = sinon.stub() -mock('simple-xmpp', { - connect: () => {}, - join: xmppJoinStub, - on: (eventName, callback) => { - simpleXmppEvents.on(eventName, callback) - }, - getRoster: () => {} -}) - -// mock outgoing -let outgoingStub = sinon.stub() -mock('./../lib/outgoing', outgoingStub) - describe('XMPP component', () => { + const simpleXmppEvents = new EventEmitter() + let logger, config, outgoingStub, xmppJoinStub + + before('Setup', (done) => { + // create default logger + logger = require('./../lib/logger')() + + // get configuration + config = require('./../lib/config')(logger, './test/config.json') + + // update logger with configuration + logger.updateConfig(config.logger) + + // mock simple-xmpp module + xmppJoinStub = sinon.stub() + mock('simple-xmpp', { + connect: () => {}, + join: xmppJoinStub, + on: (eventName, callback) => { + simpleXmppEvents.on(eventName, callback) + }, + getRoster: () => {} + }) + + // mock outgoing + outgoingStub = sinon.stub() + mock('./../lib/outgoing', outgoingStub) + + done() + }) + + after('Remove mocks', (done) => { + mock.stopAll() + done() + }) + beforeEach('Reset outgoing stub history', function () { outgoingStub.resetHistory() })