diff --git a/.env.example b/.env.example index 3d9e1300b9..f8d38b01fa 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,9 @@ TACTICAL_IDM_URL=http://localhost:8003 PERMIT_REPOSITORY_URL=http://localhost:8004 RETURNS_URL=http://localhost:8006 +# JWT Auth token shared by all legacy external services +LEGACY_AUTH_TOKEN=longvalueofnumbersandletters.inbothcases.splitin3by2periods + # External Charging Module JWT (AWS Cognito) service CHARGING_MODULE_TOKEN_URL=https://myinstance.amazoncognito.com CHARGING_MODULE_TOKEN_USERNAME=valuefullofnumbersandlettersinlowercase diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03c074db7f..cf3d0e965f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: TACTICAL_IDM_URL: http://localhost:8003 PERMIT_REPOSITORY_URL: http://localhost:8004 RETURNS_URL: http://localhost:8006 + # JWT Auth token shared by all legacy external services + LEGACY_AUTH_TOKEN: longvalueofnumbersandletters.inbothcases.splitin3by2periods # External Charging Module JWT (AWS Cognito) service CHARGING_MODULE_TOKEN_URL: https://myinstance.amazoncognito.com CHARGING_MODULE_TOKEN_USERNAME: valuefullofnumbersandlettersinlowercase diff --git a/app/lib/charging-module-request.lib.js b/app/lib/charging-module-request.lib.js index aea4d07d09..b73ee66305 100644 --- a/app/lib/charging-module-request.lib.js +++ b/app/lib/charging-module-request.lib.js @@ -24,9 +24,9 @@ async function get (path) { } /** - * Sends a PATCH request to the Charging Module for the provided route + * Sends a PATCH request to the Charging Module for the provided path * - * @param {string} path The route to send the request to (do not include the starting /) + * @param {string} path The path to send the request to (do not include the starting /) * * @returns {Object} result An object representing the result of the request * @returns {boolean} result.succeeded Whether the request was successful @@ -39,7 +39,7 @@ async function patch (path) { } /** - * Sends a POST request to the Charging Module for the provided route + * Sends a POST request to the Charging Module for the provided path * * @param {string} path The path to send the request to (do not include the starting /) * @param {Object} [body] The body of the request @@ -55,7 +55,7 @@ async function post (path, body = {}) { } /** - * Sends a request to the Charging Module to the provided using the provided RequestLib method + * Sends a request to the Charging Module using the provided RequestLib method * * @param {string} path The path that you wish to connect to (do not include the starting /) * @param {Object} method An instance of a RequestLib method which will be used to send the request diff --git a/app/lib/legacy-request.lib.js b/app/lib/legacy-request.lib.js new file mode 100644 index 0000000000..a54a9e76e5 --- /dev/null +++ b/app/lib/legacy-request.lib.js @@ -0,0 +1,185 @@ +'use strict' + +/** + * Use for making http requests to the legacy web services + * @module LegacyRequestLib + */ + +const RequestLib = require('./request.lib.js') + +const servicesConfig = require('../../config/services.config.js') + +const services = { + // REPO-NAME - PM2 NAME + // water-abstraction-service - service-background + background: { + base: servicesConfig.serviceBackground.url, + api: 'water/1.0' + }, + // water-abstraction-tactical-crm - tactical-crm + crm: { + base: servicesConfig.tacticalCrm.url, + api: 'crm/1.0' + }, + // water-abstraction-ui - ui + external: { + base: servicesConfig.externalUi.url, + api: '' + }, + // water-abstraction-tactical-idm - tactical-idm + idm: { + base: servicesConfig.tacticalIdm.url, + api: 'idm/1.0' + }, + // water-abstraction-import - import + import: { + base: servicesConfig.import.url, + api: 'import/1.0' + }, + // water-abstraction-ui - internal-ui + internal: { + base: servicesConfig.internalUi.url, + api: '' + }, + // water-abstraction-permit-repository - permit-repository + permits: { + base: servicesConfig.permitRepository.url, + api: 'API/1.0/' + }, + // water-abstraction-reporting - reporting + reporting: { + base: servicesConfig.reporting.url, + api: 'reporting/1.0' + }, + // water-abstraction-returns - returns + returns: { + base: servicesConfig.returns.url, + api: 'returns/1.0' + }, + // water-abstraction-service - water-api + water: { + base: servicesConfig.serviceForeground.url, + api: 'water/1.0' + } +} + +/** + * Sends a GET request to the legacy service for the provided path + * + * @param {string} serviceName name of the legacy service to call (background, crm, external, idm, import, internal, + * permits, reporting, returns or water) + * @param {string} path The path to send the request to (do not include the starting /) + * @param {boolean} apiRequest whether the request is to the service's API endpoints + * + * @returns {Object} result An object representing the result of the request + * @returns {boolean} result.succeeded Whether the request was successful + * @returns {Object} result.response The legacy service's response if successful or the error response if not. + */ +async function get (serviceName, path, apiRequest = true) { + return await _sendRequest(RequestLib.get, serviceName, path, apiRequest) +} + +/** + * Sends a POST request to the legacy service for the provided path + * + * @param {string} serviceName name of the legacy service to call (background, crm, external, idm, import, internal, + * permits, reporting, returns or water) + * @param {string} path the path to send the request to (do not include the starting /) + * @param {boolean} apiRequest whether the request is to the service's API endpoints + * @param {Object} [body] optional body to be sent to the service as json + * + * @returns {Object} result An object representing the result of the request + * @returns {boolean} result.succeeded Whether the request was successful + * @returns {Object} result.response The legacy service's response if successful or the error response if not. + */ +async function post (serviceName, path, apiRequest = true, body = {}) { + return await _sendRequest(RequestLib.post, serviceName, path, apiRequest, body) +} + +/** + * Sends a request to a legacy service using the provided RequestLib method + * + * @param {Object} method an instance of a RequestLib method which will be used to send the request + * @param {string} serviceName name of the legacy service (see `services`) + * @param {string} path the path that you wish to connect to (do not include the starting /) + * @param {boolean} apiRequest whether the request is to the service's API endpoints + * @param {Object} body body to be sent to the service as json + * + * @returns {Object} The result of the request passed back from RequestLib + */ +async function _sendRequest (method, serviceName, path, apiRequest, body) { + const service = _service(serviceName) + const options = _requestOptions(service, apiRequest, body) + + const result = await method(path, options) + + return _parseResult(result) +} + +function _service (serviceName) { + const service = services[serviceName.trim().toLowerCase()] + + if (!service) { + throw new Error(`Request to unknown legacy service ${serviceName}`) + } + + return service +} + +/** + * Additional options that will be added to the default options used by RequestLib + * + * We use it to set + * + * - the base URL for the request + * - the authorization header with shared legacy JWT Auth token + * - the body (which is always a JSON object) for our POST requests + * - the option to tell Got that we expect JSON responses. This means Got will automatically handle parsing the + * response to a JSON object for us + * + * @param {Object} service which legacy service we are connecting with + * @param {boolean} apiRequest whether the request is to the service's API endpoints + * @param {Object} body the request body if applicable + * + * @returns Legacy specific options to be passed to RequestLib + */ +function _requestOptions (service, apiRequest, body) { + const prefixUrl = apiRequest ? new URL(service.api, service.base).href : service.base + + return { + prefixUrl, + headers: { + authorization: `Bearer ${servicesConfig.legacyAuthToken}` + }, + responseType: 'json', + json: body + } +} + +/** + * Parses the charging module response returned from RequestLib + * + * @param {Object} result The result object returned by RequestLib + * + * @returns {Object} If result was not an error, a parsed version of the response + */ +function _parseResult (result) { + const { body, statusCode } = result.response + + if (body) { + return { + succeeded: result.succeeded, + response: { + statusCode, + body + } + } + } + + return result +} + +module.exports = { + get, + post +} diff --git a/app/services/health/info.service.js b/app/services/health/info.service.js index 145254cbf5..61c6ea45e2 100644 --- a/app/services/health/info.service.js +++ b/app/services/health/info.service.js @@ -12,6 +12,7 @@ const exec = util.promisify(ChildProcess.exec) const ChargingModuleRequestLib = require('../../lib/charging-module-request.lib.js') const RequestLib = require('../../lib/request.lib.js') +const LegacyRequestLib = require('../../lib/legacy-request.lib.js') const servicesConfig = require('../../../config/services.config.js') @@ -103,27 +104,27 @@ function _getImportJobsData () { } async function _getAppData () { - const healthInfoPath = '/health/info' + const healthInfoPath = 'health/info' + const services = [ - { name: 'Service - foreground', url: new URL(healthInfoPath, servicesConfig.serviceForeground.url) }, - { name: 'Service - background', url: new URL(healthInfoPath, servicesConfig.serviceBackground.url) }, - { name: 'Reporting', url: new URL(healthInfoPath, servicesConfig.reporting.url) }, - { name: 'Import', url: new URL(healthInfoPath, servicesConfig.import.url) }, - { name: 'Tactical CRM', url: new URL(healthInfoPath, servicesConfig.tacticalCrm.url) }, - { name: 'External UI', url: new URL(healthInfoPath, servicesConfig.externalUi.url) }, - { name: 'Internal UI', url: new URL(healthInfoPath, servicesConfig.internalUi.url) }, - { name: 'Tactical IDM', url: new URL(healthInfoPath, servicesConfig.tacticalIdm.url) }, - { name: 'Permit repository', url: new URL(healthInfoPath, servicesConfig.permitRepository.url) }, - { name: 'Returns', url: new URL(healthInfoPath, servicesConfig.returns.url) } + { name: 'Service - foreground', serviceName: 'water' }, + { name: 'Service - background', serviceName: 'background' }, + { name: 'Reporting', serviceName: 'reporting' }, + { name: 'Import', serviceName: 'import' }, + { name: 'Tactical CRM', serviceName: 'crm' }, + { name: 'External UI', serviceName: 'external' }, + { name: 'Internal UI', serviceName: 'internal' }, + { name: 'Tactical IDM', serviceName: 'idm' }, + { name: 'Permit repository', serviceName: 'permits' }, + { name: 'Returns', serviceName: 'returns' } ] for (const service of services) { - const result = await RequestLib.get(service.url.href) + const result = await LegacyRequestLib.get(service.serviceName, healthInfoPath, false) if (result.succeeded) { - const data = JSON.parse(result.response.body) - service.version = data.version - service.commit = data.commit + service.version = result.response.body.version + service.commit = result.response.body.commit service.jobs = service.name === 'Import' ? _getImportJobsData() : [] } else { service.version = _parseFailedRequestResult(result) diff --git a/config/services.config.js b/config/services.config.js index df71148f4f..4485fab15f 100644 --- a/config/services.config.js +++ b/config/services.config.js @@ -21,6 +21,7 @@ const config = { password: process.env.CHARGING_MODULE_TOKEN_PASSWORD } }, + legacyAuthToken: process.env.LEGACY_AUTH_TOKEN, serviceForeground: { url: process.env.SERVICE_FOREGROUND_URL }, diff --git a/test/lib/legacy-request.lib.test.js b/test/lib/legacy-request.lib.test.js new file mode 100644 index 0000000000..ebbacae2f8 --- /dev/null +++ b/test/lib/legacy-request.lib.test.js @@ -0,0 +1,212 @@ +'use strict' + +// Test framework dependencies +const Lab = require('@hapi/lab') +const Code = require('@hapi/code') +const Sinon = require('sinon') + +const { describe, it, beforeEach, afterEach } = exports.lab = Lab.script() +const { expect } = Code + +// Test helpers +const servicesConfig = require('../../config/services.config.js') + +// Things we need to stub +const RequestLib = require('../../app/lib/request.lib.js') + +// Thing under test +const LegacyRequestLib = require('../../app/lib/legacy-request.lib.js') + +describe('LegacyRequestLib', () => { + const testPath = 'abstraction/info' + + afterEach(() => { + Sinon.restore() + }) + + describe('#get()', () => { + describe('when the request succeeds', () => { + beforeEach(async () => { + Sinon.stub(RequestLib, 'get').resolves({ + succeeded: true, + response: { + statusCode: 200, + body: { version: '3.1.2', commit: '70708cff586cc410c11af25cf8fd296f987d7f36' } + } + }) + }) + + it('calls the legacy service with the required options', async () => { + await LegacyRequestLib.get('import', testPath) + + const requestArgs = RequestLib.get.firstCall.args + + expect(requestArgs[0]).to.equal(testPath) + expect(requestArgs[1].prefixUrl).to.equal(`${servicesConfig.import.url}/import/1.0`) + expect(requestArgs[1].headers).to.equal({ authorization: `Bearer ${servicesConfig.legacyAuthToken}` }) + expect(requestArgs[1].responseType).to.equal('json') + expect(requestArgs[1].json).to.be.undefined() + }) + + it('returns a `true` success status', async () => { + const result = await LegacyRequestLib.get('import', testPath) + + expect(result.succeeded).to.be.true() + }) + + it('returns the response body as an object', async () => { + const result = await LegacyRequestLib.get('import', testPath) + + expect(result.response.body.version).to.equal('3.1.2') + expect(result.response.body.commit).to.equal('70708cff586cc410c11af25cf8fd296f987d7f36') + }) + + it('returns the status code', async () => { + const result = await LegacyRequestLib.get('import', testPath) + + expect(result.response.statusCode).to.equal(200) + }) + + it('can handle none API requests', async () => { + await LegacyRequestLib.get('import', testPath, false) + + const requestArgs = RequestLib.get.firstCall.args + + expect(requestArgs[1].prefixUrl).to.equal(servicesConfig.import.url) + }) + }) + + describe('when the request fails', () => { + beforeEach(async () => { + Sinon.stub(RequestLib, 'get').resolves({ + succeeded: false, + response: { + statusCode: 404, + statusMessage: 'Not Found', + body: { statusCode: 404, error: 'Not Found', message: 'Not Found' } + } + }) + }) + + it('returns a `false` success status', async () => { + const result = await LegacyRequestLib.get('import', testPath) + + expect(result.succeeded).to.be.false() + }) + + it('returns the error response', async () => { + const result = await LegacyRequestLib.get('import', testPath) + + expect(result.response.body.message).to.equal('Not Found') + }) + + it('returns the status code', async () => { + const result = await LegacyRequestLib.get('import', testPath) + + expect(result.response.statusCode).to.equal(404) + }) + }) + + describe('when the request is to an unknown legacy service', () => { + it('throws an error', async () => { + await expect(LegacyRequestLib.get('foobar', testPath)) + .to + .reject(Error, 'Request to unknown legacy service foobar') + }) + }) + }) + + describe('#post()', () => { + const requestBody = { name: 'water' } + + describe('when the request succeeds', () => { + beforeEach(async () => { + Sinon.stub(RequestLib, 'post').resolves({ + succeeded: true, + response: { + statusCode: 200, + body: { version: '3.1.2', commit: '70708cff586cc410c11af25cf8fd296f987d7f36' } + } + }) + }) + + it('calls the legacy service with the required options', async () => { + await LegacyRequestLib.post('import', testPath, true, requestBody) + + const requestArgs = RequestLib.post.firstCall.args + + expect(requestArgs[0]).to.equal(testPath) + expect(requestArgs[1].prefixUrl).to.equal(`${servicesConfig.import.url}/import/1.0`) + expect(requestArgs[1].headers).to.equal({ authorization: `Bearer ${servicesConfig.legacyAuthToken}` }) + expect(requestArgs[1].responseType).to.equal('json') + expect(requestArgs[1].json).to.equal(requestBody) + }) + + it('returns a `true` success status', async () => { + const result = await LegacyRequestLib.post('import', testPath, true, requestBody) + + expect(result.succeeded).to.be.true() + }) + + it('returns the response body as an object', async () => { + const result = await LegacyRequestLib.post('import', testPath, true, requestBody) + + expect(result.response.body.version).to.equal('3.1.2') + expect(result.response.body.commit).to.equal('70708cff586cc410c11af25cf8fd296f987d7f36') + }) + + it('returns the status code', async () => { + const result = await LegacyRequestLib.post('import', testPath, true, requestBody) + + expect(result.response.statusCode).to.equal(200) + }) + + it('can handle none API requests', async () => { + await LegacyRequestLib.post('import', testPath, false, requestBody) + + const requestArgs = RequestLib.post.firstCall.args + + expect(requestArgs[1].prefixUrl).to.equal(servicesConfig.import.url) + }) + }) + + describe('when the request fails', () => { + beforeEach(async () => { + Sinon.stub(RequestLib, 'post').resolves({ + succeeded: false, + response: { + statusCode: 404, + statusMessage: 'Not Found', + body: { statusCode: 404, error: 'Not Found', message: 'Not Found' } + } + }) + }) + + it('returns a `false` success status', async () => { + const result = await LegacyRequestLib.post('import', testPath, true, requestBody) + + expect(result.succeeded).to.be.false() + }) + + it('returns the error response', async () => { + const result = await LegacyRequestLib.post('import', testPath, true, requestBody) + + expect(result.response.body.message).to.equal('Not Found') + }) + + it('returns the status code', async () => { + const result = await LegacyRequestLib.post('import', testPath, true, requestBody) + + expect(result.response.statusCode).to.equal(404) + }) + }) + + describe('when the request is to an unknown legacy service', () => { + it('throws an error', async () => { + await expect(LegacyRequestLib.post('foobar', testPath, true, requestBody)) + .to + .reject(Error, 'Request to unknown legacy service foobar') + }) + }) + }) +}) diff --git a/test/services/health/info.service.test.js b/test/services/health/info.service.test.js index c0fff081be..f1979c6136 100644 --- a/test/services/health/info.service.test.js +++ b/test/services/health/info.service.test.js @@ -14,6 +14,7 @@ const servicesConfig = require('../../../config/services.config.js') // Things we need to stub const ChargingModuleRequestLib = require('../../../app/lib/charging-module-request.lib.js') +const LegacyRequestLib = require('../../../app/lib/legacy-request.lib.js') const RequestLib = require('../../../app/lib/request.lib.js') // Thing under test @@ -34,44 +35,29 @@ describe('Info service', () => { } } }, - app: { succeeded: true, response: { statusCode: 200, body: '{ "version": "9.0.99", "commit": "99d0e8c" }' } } + app: { succeeded: true, response: { statusCode: 200, body: { version: '9.0.99', commit: '99d0e8c' } } } } let chargingModuleRequestLibStub + let legacyRequestLibStub let requestLibStub beforeEach(() => { + chargingModuleRequestLibStub = Sinon.stub(ChargingModuleRequestLib, 'get') + legacyRequestLibStub = Sinon.stub(LegacyRequestLib, 'get') requestLibStub = Sinon.stub(RequestLib, 'get') + // These requests will remain unchanged throughout the tests. We do alter the ones to the AddressFacade and the // water-api (foreground-service) though, which is why they are defined separately in each test. - requestLibStub - .withArgs(`${servicesConfig.serviceBackground.url}/health/info`) - .resolves(goodRequestResults.app) - requestLibStub - .withArgs(`${servicesConfig.reporting.url}/health/info`) - .resolves(goodRequestResults.app) - requestLibStub - .withArgs(`${servicesConfig.import.url}/health/info`) - .resolves(goodRequestResults.app) - requestLibStub - .withArgs(`${servicesConfig.tacticalCrm.url}/health/info`) - .resolves(goodRequestResults.app) - requestLibStub - .withArgs(`${servicesConfig.externalUi.url}/health/info`) - .resolves(goodRequestResults.app) - requestLibStub - .withArgs(`${servicesConfig.internalUi.url}/health/info`) - .resolves(goodRequestResults.app) - requestLibStub - .withArgs(`${servicesConfig.tacticalIdm.url}/health/info`) - .resolves(goodRequestResults.app) - requestLibStub - .withArgs(`${servicesConfig.permitRepository.url}/health/info`) - .resolves(goodRequestResults.app) - requestLibStub - .withArgs(`${servicesConfig.returns.url}/health/info`) - .resolves(goodRequestResults.app) + legacyRequestLibStub.withArgs('background', 'health/info', false).resolves(goodRequestResults.app) + legacyRequestLibStub.withArgs('reporting', 'health/info', false).resolves(goodRequestResults.app) + legacyRequestLibStub.withArgs('import', 'health/info', false).resolves(goodRequestResults.app) + legacyRequestLibStub.withArgs('crm', 'health/info', false).resolves(goodRequestResults.app) + legacyRequestLibStub.withArgs('external', 'health/info', false).resolves(goodRequestResults.app) + legacyRequestLibStub.withArgs('internal', 'health/info', false).resolves(goodRequestResults.app) + legacyRequestLibStub.withArgs('idm', 'health/info', false).resolves(goodRequestResults.app) + legacyRequestLibStub.withArgs('permits', 'health/info', false).resolves(goodRequestResults.app) + legacyRequestLibStub.withArgs('returns', 'health/info', false).resolves(goodRequestResults.app) - chargingModuleRequestLibStub = Sinon.stub(ChargingModuleRequestLib, 'get') chargingModuleRequestLibStub .withArgs('status') .resolves(goodRequestResults.chargingModule) @@ -87,9 +73,7 @@ describe('Info service', () => { requestLibStub .withArgs(`${servicesConfig.addressFacade.url}/address-service/hola`) .resolves(goodRequestResults.addressFacade) - requestLibStub - .withArgs(`${servicesConfig.serviceForeground.url}/health/info`) - .resolves(goodRequestResults.app) + legacyRequestLibStub.withArgs('water', 'health/info', false).resolves(goodRequestResults.app) // Unfortunately, this convoluted test setup is the only way we've managed to stub how the promisified version of // `child-process.exec()` behaves in the module under test. @@ -131,9 +115,7 @@ describe('Info service', () => { requestLibStub .withArgs(`${servicesConfig.addressFacade.url}/address-service/hola`) .resolves(goodRequestResults.addressFacade) - requestLibStub - .withArgs(`${servicesConfig.serviceForeground.url}/health/info`) - .resolves(goodRequestResults.app) + legacyRequestLibStub.withArgs('water', 'health/info', false).resolves(goodRequestResults.app) }) describe('is not running', () => { @@ -226,9 +208,7 @@ describe('Info service', () => { requestLibStub .withArgs(`${servicesConfig.addressFacade.url}/address-service/hola`) .resolves(badResult) - requestLibStub - .withArgs(`${servicesConfig.serviceForeground.url}/health/info`) - .resolves(badResult) + legacyRequestLibStub.withArgs('water', 'health/info', false).resolves(badResult) }) it('handles the error and still returns a result for the other services', async () => { @@ -246,14 +226,12 @@ describe('Info service', () => { describe('returns a 5xx response', () => { beforeEach(async () => { - const badResult = { succeeded: false, response: { statusCode: 500, body: 'Kaboom' } } + const badResult = { succeeded: false, response: { statusCode: 500, body: { message: 'Kaboom' } } } requestLibStub .withArgs(`${servicesConfig.addressFacade.url}/address-service/hola`) .resolves(badResult) - requestLibStub - .withArgs(`${servicesConfig.serviceForeground.url}/health/info`) - .resolves(badResult) + legacyRequestLibStub.withArgs('water', 'health/info', false).resolves(badResult) }) it('handles the error and still returns a result for the other services', async () => {