From 00efb56eda5e98957c4caab6d85d64b7d60f9fa5 Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Mon, 27 May 2024 17:17:51 -0700 Subject: [PATCH] add unit tests. refactor encryption service. add database model/migration for the configuration of external apis. Signed-off-by: Jason Sherman --- .devcontainer/chefs_local/test.json | 4 + .vscode/launch.json | 2 +- .../tests/unit/utils/constants.spec.js | 1 + app/src/components/encryption.js | 84 ----- app/src/components/encryptionService.js | 166 ++++++++++ .../20240521210143_046_external_api.js | 40 +++ app/src/forms/common/models/index.js | 1 + .../forms/common/models/tables/externalAPI.js | 90 ++++++ app/src/forms/form/controller.js | 24 ++ app/src/forms/form/routes.js | 12 + app/src/forms/form/service.js | 48 +++ app/src/forms/proxy/controller.js | 33 +- app/src/forms/proxy/service.js | 90 +++++- .../unit/components/encryptionService.spec.js | 112 +++++++ app/tests/unit/forms/proxy/controller.spec.js | 124 +++++++ app/tests/unit/forms/proxy/routes.spec.js | 149 +++++++++ app/tests/unit/forms/proxy/service.spec.js | 302 ++++++++++++++++++ app/tests/unit/routes/v1.spec.js | 2 +- 18 files changed, 1175 insertions(+), 109 deletions(-) delete mode 100644 app/src/components/encryption.js create mode 100644 app/src/components/encryptionService.js create mode 100644 app/src/db/migrations/20240521210143_046_external_api.js create mode 100644 app/src/forms/common/models/tables/externalAPI.js create mode 100644 app/tests/unit/components/encryptionService.spec.js create mode 100644 app/tests/unit/forms/proxy/controller.spec.js create mode 100644 app/tests/unit/forms/proxy/routes.spec.js create mode 100644 app/tests/unit/forms/proxy/service.spec.js diff --git a/.devcontainer/chefs_local/test.json b/.devcontainer/chefs_local/test.json index 6aa8fd1da..935ebf112 100644 --- a/.devcontainer/chefs_local/test.json +++ b/.devcontainer/chefs_local/test.json @@ -56,6 +56,10 @@ "windowMs": "900000", "max": "100" } + }, + "encryption": { + "proxy": "5fb2054478353fd8d514056d1745b3a9eef066deadda4b90967af7ca65ce6505", + "db": "055cc521987c070c72880d988fd40233fe86efe64e99960fd93bb87145beb6c9" } }, "serviceClient": { diff --git a/.vscode/launch.json b/.vscode/launch.json index c62380825..0ba126850 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -50,7 +50,7 @@ //"env": { "NODE_ENV": "test" }, "program": "${workspaceFolder}/app/node_modules/.bin/jest", "args": [ - "${fileBasenameNoExtension}", + "${file}", "--config", "${workspaceFolder}/app/jest.config.js", "--coverage=false" diff --git a/app/frontend/tests/unit/utils/constants.spec.js b/app/frontend/tests/unit/utils/constants.spec.js index dac0c0353..d5d499c6a 100644 --- a/app/frontend/tests/unit/utils/constants.spec.js +++ b/app/frontend/tests/unit/utils/constants.spec.js @@ -14,6 +14,7 @@ describe('Constants', () => { USERS: '/users', UTILS: '/utils', FILES_API_ACCESS: '/filesApiAccess', + PROXY: '/proxy', }); }); diff --git a/app/src/components/encryption.js b/app/src/components/encryption.js deleted file mode 100644 index 077534a65..000000000 --- a/app/src/components/encryption.js +++ /dev/null @@ -1,84 +0,0 @@ -const crypto = require('crypto'); - -const ENCRYPTION_TYPES = { - AES_256_GCM: 'aes-256-gcm', -}; - -class Encryption { - // eslint-disable-next-line no-unused-vars - encrypt(payload, masterkey) { - throw new Error('encrypt must be overridden.'); - } - // eslint-disable-next-line no-unused-vars - decrypt(encdata, masterkey) { - throw new Error('decrypt must be overridden.'); - } -} - -class Aes256Gcm extends Encryption { - // - // For a masterkey: - // we want a sha256 hash: 256 bits/32 bytes/64 characters - // to generate: - // crypto.createHash('sha256').update("sometext").digest('hex'); - // - encrypt(payload, masterkey) { - // random initialization vector - const iv = crypto.randomBytes(16); - - // random salt - const salt = crypto.randomBytes(64); - - // derive encryption key: 32 byte key length - // in assumption the masterkey is a cryptographic and NOT a password there is no need for - // a large number of iterations. It may can replaced by HKDF - // the value of 2145 is randomly chosen! - const key = crypto.pbkdf2Sync(masterkey, salt, 2145, 32, 'sha512'); - - // AES 256 GCM Mode - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); - - // encrypt the given text - const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]); - - // extract the auth tag - const tag = cipher.getAuthTag(); - - // generate output - return Buffer.concat([salt, iv, tag, encrypted]).toString('base64'); - } - decrypt(encdata, masterkey) { - // base64 decoding - const bData = Buffer.from(encdata, 'base64'); - - // convert data to buffers - const salt = bData.subarray(0, 64); - const iv = bData.subarray(64, 80); - const tag = bData.subarray(80, 96); - const payload = bData.subarray(96); - - // derive key using; 32 byte key length - const key = crypto.pbkdf2Sync(masterkey, salt, 2145, 32, 'sha512'); - - // AES 256 GCM Mode - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); - decipher.setAuthTag(tag); - - // encrypt the given text - const decrypted = decipher.update(payload, 'binary', 'utf8') + decipher.final('utf8'); - - return decrypted; - } -} - -module.exports = { - createEncryption: (type) => { - switch (type) { - case ENCRYPTION_TYPES.AES_256_GCM: - return new Aes256Gcm(); - default: - throw new Error('Invalid encryption type'); - } - }, - ENCRYPTION_TYPES: Object.freeze(ENCRYPTION_TYPES), -}; diff --git a/app/src/components/encryptionService.js b/app/src/components/encryptionService.js new file mode 100644 index 000000000..78b6ddfdc --- /dev/null +++ b/app/src/components/encryptionService.js @@ -0,0 +1,166 @@ +const config = require('config'); +const crypto = require('crypto'); + +const SERVICE = 'EncryptionService'; + +const ENCRYPTION_KEYS = { + PROXY: 'proxy', + DATABASE: 'db', +}; +const ENCRYPTION_ALGORITHMS = { + AES_256_GCM: 'aes-256-gcm', +}; + +class Encryption { + // eslint-disable-next-line no-unused-vars + encrypt(payload, masterkey) { + throw new Error('encrypt must be overridden.'); + } + // eslint-disable-next-line no-unused-vars + decrypt(encdata, masterkey) { + throw new Error('decrypt must be overridden.'); + } +} + +class Aes256Gcm extends Encryption { + // + // For a masterkey: + // we want a sha256 hash: 256 bits/32 bytes/64 characters + // to generate: + // crypto.createHash('sha256').update("sometext").digest('hex'); + // + encrypt(payload, masterkey) { + // random initialization vector + const iv = crypto.randomBytes(16); + + // random salt + const salt = crypto.randomBytes(64); + + // derive encryption key: 32 byte key length + // in assumption the masterkey is a cryptographic and NOT a password there is no need for + // a large number of iterations. It may can replaced by HKDF + // the value of 2145 is randomly chosen! + const key = crypto.pbkdf2Sync(masterkey, salt, 2145, 32, 'sha512'); + + // AES 256 GCM Mode + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + + // encrypt the given text/json + const strPayload = typeof payload === 'string' || payload instanceof String ? payload : JSON.stringify(payload); + const encrypted = Buffer.concat([cipher.update(strPayload, 'utf8'), cipher.final()]); + + // extract the auth tag + const tag = cipher.getAuthTag(); + + // generate output + return Buffer.concat([salt, iv, tag, encrypted]).toString('base64'); + } + decrypt(encdata, masterkey) { + // base64 decoding + const bData = Buffer.from(encdata, 'base64'); + + // convert data to buffers + const salt = bData.subarray(0, 64); + const iv = bData.subarray(64, 80); + const tag = bData.subarray(80, 96); + const payload = bData.subarray(96); + + // derive key using; 32 byte key length + const key = crypto.pbkdf2Sync(masterkey, salt, 2145, 32, 'sha512'); + + // AES 256 GCM Mode + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + + // encrypt the given text + const decrypted = decipher.update(payload, 'binary', 'utf8') + decipher.final('utf8'); + + return decrypted; + } +} + +class EncryptionService { + constructor({ keys, algorithms }) { + if (!keys || !algorithms) { + throw new Error(`${SERVICE} is not configured. Check configuration.`); + } + + this.keys = keys; + this.algorithms = algorithms; + } + + _callAlgorithm(operation, algorithmName, masterkey, data) { + const algo = this.algorithms[algorithmName]; + if (algo) { + try { + if (operation == 'encryption') { + return algo.encrypt(data, masterkey); + } else { + return algo.decrypt(data, masterkey); + } + } catch (error) { + throw new Error(`${SERVICE} could not perform ${operation} using algorithm '${algorithmName}'. ${error.message}`); + } + } else { + throw new Error(`${SERVICE} does not support ${algorithmName} algorithm.`); + } + } + + _callAlgorithmWithKeyName(operation, algorithmName, keyName, data) { + const masterkey = this.keys[keyName]; + if (masterkey) { + return this._callAlgorithm(operation, algorithmName, masterkey, data); + } else { + throw new Error(`${SERVICE} does not have encryption key: '${keyName}'.`); + } + } + + encryptExternal(algorithmName, key, payload) { + return this._callAlgorithm('encryption', algorithmName, key, payload); + } + + decryptExternal(algorithmName, key, encdata) { + return this._callAlgorithm('decryption', algorithmName, key, encdata); + } + + encrypt(algorithmName, keyName, payload) { + return this._callAlgorithmWithKeyName('encryption', algorithmName, keyName, payload); + } + + decrypt(algorithmName, keyName, encdata) { + return this._callAlgorithmWithKeyName('decryption', algorithmName, keyName, encdata); + } + + encryptProxy(payload) { + return this.encrypt(ENCRYPTION_ALGORITHMS.AES_256_GCM, ENCRYPTION_KEYS.PROXY, payload); + } + + decryptProxy(payload) { + return this.decrypt(ENCRYPTION_ALGORITHMS.AES_256_GCM, ENCRYPTION_KEYS.PROXY, payload); + } + + encryptDb(payload) { + return this.encrypt(ENCRYPTION_ALGORITHMS.AES_256_GCM, ENCRYPTION_KEYS.DATABASE, payload); + } + + decryptDb(payload) { + return this.decrypt(ENCRYPTION_ALGORITHMS.AES_256_GCM, ENCRYPTION_KEYS.DATABASE, payload); + } +} + +const proxy = config.get('server.encryption.proxy'); +const db = config.get('server.encryption.db'); + +const keys = { proxy: proxy, db: db }; +const algorithms = { 'aes-256-gcm': new Aes256Gcm() }; + +let encryptionService = new EncryptionService({ + keys: keys, + algorithms: algorithms, +}); + +module.exports = { + encryptionService: encryptionService, + ENCRYPTION_ALGORITHMS: Object.freeze(ENCRYPTION_ALGORITHMS), + ENCRYPTION_KEYS: Object.freeze(ENCRYPTION_KEYS), +}; diff --git a/app/src/db/migrations/20240521210143_046_external_api.js b/app/src/db/migrations/20240521210143_046_external_api.js new file mode 100644 index 000000000..50c97d64d --- /dev/null +++ b/app/src/db/migrations/20240521210143_046_external_api.js @@ -0,0 +1,40 @@ +const stamps = require('../stamps'); + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +exports.up = function (knex) { + return Promise.resolve().then(() => + knex.schema.createTable('external_api', (table) => { + table.uuid('id').primary(); + table.uuid('formId').references('id').inTable('form').notNullable().index(); + table.string('name', 255).notNullable(); + table.string('endpointUrl').notNullable(); + + table.boolean('sendApiKey').defaultTo(false); + table.string('apiKeyHeader'); + table.string('apiKey'); + + table.boolean('sendUserToken').defaultTo(false); + table.string('userTokenHeader'); + table.boolean('userTokenBearer').defaultTo(true); + + table.boolean('sendUserInfo').defaultTo(false); + table.string('userInfoHeader'); + table.boolean('userInfoEncrypted').defaultTo(false); + table.string('userInfoEncryptionKey'); + table.string('userInfoEncryptionAlgo'); + stamps(knex, table); + }) + ); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return Promise.resolve().then(() => knex.schema.dropTableIfExists('external_api')); +}; diff --git a/app/src/forms/common/models/index.js b/app/src/forms/common/models/index.js index 4d8d723af..729a487ec 100644 --- a/app/src/forms/common/models/index.js +++ b/app/src/forms/common/models/index.js @@ -24,6 +24,7 @@ module.exports = { Label: require('./tables/label'), FormComponentsProactiveHelp: require('./tables/formComponentsProactiveHelp'), FormSubscription: require('./tables/formSubscription'), + ExternalAPI: require('./tables/externalAPI'), // Views FormSubmissionUserPermissions: require('./views/formSubmissionUserPermissions'), diff --git a/app/src/forms/common/models/tables/externalAPI.js b/app/src/forms/common/models/tables/externalAPI.js new file mode 100644 index 000000000..ccb527c00 --- /dev/null +++ b/app/src/forms/common/models/tables/externalAPI.js @@ -0,0 +1,90 @@ +const { Model } = require('objection'); +const { Timestamps } = require('../mixins'); +const { Regex } = require('../../constants'); +const stamps = require('../jsonSchema').stamps; + +const { encryptionService } = require('../../../../components/encryptionService'); + +class ExternalAPI extends Timestamps(Model) { + static get tableName() { + return 'external_api'; + } + + static get modifiers() { + return { + filterFormId(query, value) { + if (value) { + query.where('formId', value); + } + }, + findByIdAndFormId(query, id, formId) { + if (id !== undefined && formId !== undefined) { + query.where('id', id).where('formId', formId); + } + }, + findByFormIdAndName(query, formId, name) { + if (name !== undefined && formId !== undefined) { + query.where('name', name).where('formId', formId); + } + }, + }; + } + + async $beforeInsert(context) { + await super.$beforeInsert(context); + if (this.apiKey) { + this.apiKey = encryptionService.encryptDb(this.apiKey); + } + if (this.userInfoEncryptionKey) { + this.userInfoEncryptionKey = encryptionService.encryptDb(this.userInfoEncryptionKey); + } + } + + async $beforeUpdate(context) { + await super.$beforeUpdate(context); + if (this.apiKey) { + this.apiKey = encryptionService.encryptDb(this.apiKey); + } + if (this.userInfoEncryptionKey) { + this.userInfoEncryptionKey = encryptionService.encryptDb(this.userInfoEncryptionKey); + } + } + + async $afterFind(context) { + await super.$afterFind(context); + if (this.apiKey) { + this.apiKey = encryptionService.decryptDb(this.apiKey); + } + if (this.userInfoEncryptionKey) { + this.userInfoEncryptionKey = encryptionService.decryptDb(this.userInfoEncryptionKey); + } + } + + static get jsonSchema() { + return { + type: 'object', + required: ['formId', 'name', 'endpointUrl'], + properties: { + id: { type: 'string', pattern: Regex.UUID }, + formId: { type: 'string', pattern: Regex.UUID }, + name: { type: 'string', minLength: 1, maxLength: 255 }, + endpointUrl: { type: 'string' }, + sendApiKey: { type: 'boolean', default: false }, + apiKeyHeader: { type: 'string' }, + apiKey: { type: 'string' }, + sendUserToken: { type: 'boolean', default: false }, + userTokenHeader: { type: 'string' }, + userTokenBearer: { type: 'boolean', default: true }, + sendUserInfo: { type: 'boolean', default: false }, + userInfoHeader: { type: 'string' }, + userInfoEncrypted: { type: 'boolean', default: false }, + userInfoEncryptionKey: { type: 'string' }, + userInfoEncryptionAlgo: { type: 'string' }, + ...stamps, + }, + additionalProperties: false, + }; + } +} + +module.exports = ExternalAPI; diff --git a/app/src/forms/form/controller.js b/app/src/forms/form/controller.js index 730627bcc..612e18d9b 100644 --- a/app/src/forms/form/controller.js +++ b/app/src/forms/form/controller.js @@ -387,4 +387,28 @@ module.exports = { next(error); } }, + listExternalAPIs: async (req, res, next) => { + try { + const response = await service.listExternalAPIs(); + res.status(200).json(response); + } catch (error) { + next(error); + } + }, + createExternalAPI: async (req, res, next) => { + try { + const response = await service.createExternalAPI(req.params.formId, req.body, req.currentUser); + res.status(200).json(response); + } catch (error) { + next(error); + } + }, + updateExternalAPI: async (req, res, next) => { + try { + const response = await service.updateExternalAPI(req.params.formId, req.params.externalAPIId, req.body, req.currentUser); + res.status(200).json(response); + } catch (error) { + next(error); + } + }, }; diff --git a/app/src/forms/form/routes.js b/app/src/forms/form/routes.js index 0dfff16d0..2b884cad0 100644 --- a/app/src/forms/form/routes.js +++ b/app/src/forms/form/routes.js @@ -171,4 +171,16 @@ routes.put('/:formId/subscriptions', hasFormPermissions([P.FORM_READ, P.FORM_UPD await controller.createOrUpdateSubscriptionDetails(req, res, next); }); +routes.get('/:formId/externalAPIs', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { + await controller.listExternalAPIs(req, res, next); +}); + +routes.post('/:formId/externalAPIs', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { + await controller.createExternalAPI(req, res, next); +}); + +routes.put('/:formId/externalAPIs/:externalAPIId', hasFormPermissions([P.FORM_READ, P.FORM_UPDATE]), async (req, res, next) => { + await controller.updateExternalAPI(req, res, next); +}); + module.exports = routes; diff --git a/app/src/forms/form/service.js b/app/src/forms/form/service.js index 74dccbac7..f3e2f60f6 100644 --- a/app/src/forms/form/service.js +++ b/app/src/forms/form/service.js @@ -22,6 +22,7 @@ const { SubmissionMetadata, FormComponentsProactiveHelp, FormSubscription, + ExternalAPI, } = require('../common/models'); const { falsey, queryUtils, checkIsFormExpired, validateScheduleObject, typeUtils } = require('../common/utils'); const { Permissions, Roles, Statuses } = require('../common/constants'); @@ -1031,6 +1032,53 @@ const service = { throw error; } }, + + // ----------------------------------------------------------------------------- + // External API + // ----------------------------------------------------------------------------- + + listExternalAPIs: (formId) => { + return ExternalAPI.query().modify('filterFormId', formId); + }, + + createExternalAPI: async (formId, data, currentUser) => { + let trx; + let id = uuidv4(); + try { + trx = await ExternalAPI.startTransaction(); + await ExternalAPI.query(trx).insert({ + id: id, + ...data, + createdBy: currentUser.usernameIdp, + }); + + await trx.commit(); + return ExternalAPI.query().findById(id); + } catch (err) { + if (trx) await trx.rollback(); + throw err; + } + }, + + updateExternalAPI: async (formId, externalAPIId, data, currentUser) => { + let trx; + try { + await ExternalAPI.query().modify('findByIdAndFormId', externalAPIId, formId).throwIfNotFound(); + trx = await ExternalAPI.startTransaction(); + await ExternalAPI.query(trx) + .modify('filterId', externalAPIId) + .update({ + ...data, + updatedBy: currentUser.usernameIdp, + }); + + await trx.commit(); + return ExternalAPI.query().findById(externalAPIId); + } catch (err) { + if (trx) await trx.rollback(); + throw err; + } + }, }; module.exports = service; diff --git a/app/src/forms/proxy/controller.js b/app/src/forms/proxy/controller.js index 14c974dc1..7700dccb1 100644 --- a/app/src/forms/proxy/controller.js +++ b/app/src/forms/proxy/controller.js @@ -1,5 +1,6 @@ const service = require('./service'); const jwtService = require('../../components/jwtService'); +const axios = require('axios'); module.exports = { generateProxyHeaders: async (req, res, next) => { @@ -12,20 +13,24 @@ module.exports = { }, callExternalApi: async (req, res, next) => { try { - const headers = await service.readProxyHeaders(req.headers); - // read external api config - // prepare external api call - // - res.status(200).json([ - { - name: headers['username'], - abbreviation: 'USER', - }, - { - name: headers['email'], - abbreviation: 'EMAIL', - }, - ]); + // read the encrypted headers and parse them... + const proxyHeaderInfo = await service.readProxyHeaders(req.headers); + // find the specified external api configuration... + const extAPI = await service.getExternalAPI(req.headers, proxyHeaderInfo); + // add path to endpoint url if included in headers... + const extUrl = service.createExternalAPIUrl(req.headers, extAPI.endpointUrl); + // build list of request headers based on configuration... + const extHeaders = service.createExternalAPIHeaders(extAPI, proxyHeaderInfo); + let axiosInstance = axios.create({ + headers: extHeaders, + }); + //for (const [key, value] of Object.entries(extHeaders)) { + // axiosInstance.defaults.headers[key] = value; + //} + // call the external api + const { data } = await axiosInstance.get(extUrl); + // if all good return data + res.status(200).json(data); } catch (error) { next(error); } diff --git a/app/src/forms/proxy/service.js b/app/src/forms/proxy/service.js index 1f83f940f..be2cbed8d 100644 --- a/app/src/forms/proxy/service.js +++ b/app/src/forms/proxy/service.js @@ -1,11 +1,27 @@ -const config = require('config'); -const { createEncryption, ENCRYPTION_TYPES } = require('../../components/encryption'); +const { encryptionService } = require('../../components/encryptionService'); +const { ExternalAPI } = require('../../forms/common/models'); -const encryptionKey = config.get('server.encryption.proxy'); -const encryption = createEncryption(ENCRYPTION_TYPES.AES_256_GCM); +const headerValue = (headers, key) => { + if (headers && key) { + try { + return headers[key.toUpperCase()] || headers[key.toLowerCase()]; + } catch (error) { + return null; + } + } else { + throw new Error('Headers or Header Name not provided.'); + } +}; + +const trimLeadingSlashes = (str) => str.replace(/^\/+|\$/g, ''); +const trimTrailingSlashes = (str) => str.replace(/\/+$/g, ''); const service = { generateProxyHeaders: async (payload, currentUser, token) => { + if (!payload || !currentUser || !currentUser.idpUserId) { + throw new Error('Cannot generate proxy headers with missing or incomplete parameters'); + } + const headerData = { formId: payload['formId'], versionId: payload['versionId'], @@ -19,25 +35,81 @@ const service = { idp: currentUser.idp, token: token, }; - const encryptedHeaderData = encryption.encrypt(headerData, encryptionKey); + const encryptedHeaderData = encryptionService.encryptProxy(headerData); return { 'X-CHEFS-PROXY-DATA': encryptedHeaderData, }; }, readProxyHeaders: async (headers) => { - const encryptedHeaderData = headers['X-CHEFS-PROXY-DATA'] || headers['x-chefs-proxy-data']; + const encryptedHeaderData = headerValue(headers, 'X-CHEFS-PROXY-DATA'); if (encryptedHeaderData) { //error check that we can decrypt it and it contains expected data... try { - const decryptedHeaderData = encryption.decrypt(encryptedHeaderData, encryptionKey); + const decryptedHeaderData = encryptionService.decryptProxy(encryptedHeaderData); const data = JSON.parse(decryptedHeaderData); return data; } catch (error) { - throw Error(`Could not decrypt proxy headers: ${error.message}`); + throw new Error(`Could not decrypt proxy headers: ${error.message}`); } } else { - throw Error('Proxy headers not found'); + throw new Error('Proxy headers not found'); + } + }, + getExternalAPI: async (headers, proxyHeaderInfo) => { + const externalApiName = headerValue(headers, 'X-CHEFS-EXTERNAL-API-NAME'); + const externalAPI = await ExternalAPI.query().modify('findByFormIdAndName', proxyHeaderInfo['formId'], externalApiName).first().throwIfNotFound(); + return externalAPI; + }, + createExternalAPIUrl: (headers, endpointUrl) => { + //check incoming request headers for path to add to the endpoint url + const path = headerValue(headers, 'X-CHEFS-EXTERNAL-API-PATH'); + if (path) { + return `${trimTrailingSlashes(endpointUrl)}/${trimLeadingSlashes(path)}`; + } + return endpointUrl; + }, + createExternalAPIHeaders: (externalAPI, proxyHeaderInfo) => { + const result = {}; + if (externalAPI.sendApiKey) { + result[externalAPI.apiKeyHeader] = externalAPI.apiKey; + } + if (externalAPI.sendUserToken) { + if (!proxyHeaderInfo || !proxyHeaderInfo.token) { + // just assume that if there is no idpUserId than it isn't a userInfo object + throw new Error('Cannot create user token headers for External API without populated proxy header info token.'); + } + const val = externalAPI.userTokenBearer ? `Bearer ${proxyHeaderInfo['token']}` : proxyHeaderInfo['token']; + result[externalAPI.userTokenHeader] = val; + } + if (externalAPI.sendUserInfo) { + if (!proxyHeaderInfo || !proxyHeaderInfo.email) { + // just assume that if there is no email than it isn't a userInfo object + throw new Error('Cannot create user headers for External API without populated proxy header info object.'); + } + + if (externalAPI.userInfoEncrypted) { + const encUserInfo = encryptionService.encryptExternal(externalAPI.userInfoEncryptionAlgo, externalAPI.userInfoEncryptionKey, proxyHeaderInfo); + result[externalAPI.userInfoHeader] = encUserInfo; + } else { + // user information (no token) + let prefix = 'X-CHEFS-USER'; + let fields = ['userId', 'username', 'firstName', 'lastName', 'fullName', 'email', 'idp']; + fields.forEach((field) => { + if (proxyHeaderInfo[field]) { + result[`${prefix}-${field}`.toUpperCase()] = proxyHeaderInfo[field]; + } + }); + // form information... + prefix = 'X-CHEFS-FORM'; + fields = ['formId', 'versionId', 'submissionId']; + fields.forEach((field) => { + if (proxyHeaderInfo[field]) { + result[`${prefix}-${field}`.toUpperCase()] = proxyHeaderInfo[field]; + } + }); + } } + return result; }, }; diff --git a/app/tests/unit/components/encryptionService.spec.js b/app/tests/unit/components/encryptionService.spec.js new file mode 100644 index 000000000..666b17220 --- /dev/null +++ b/app/tests/unit/components/encryptionService.spec.js @@ -0,0 +1,112 @@ +const { MockModel } = require('../../common/dbHelper'); +const { encryptionService, ENCRYPTION_ALGORITHMS, ENCRYPTION_KEYS } = require('../../../src/components/encryptionService'); + +// change these as appropriate after adding new default keys/algos... +const KEY_COUNT = 2; +const ALGO_COUNT = 1; + +beforeEach(() => { + MockModel.mockReset(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('encryptionService', () => { + const assertService = (srv) => { + expect(srv).toBeTruthy(); + expect(Object.entries(srv.keys)).toHaveLength(KEY_COUNT); + expect(Object.entries(srv.algorithms)).toHaveLength(ALGO_COUNT); + }; + + it('should return a service', () => { + assertService(encryptionService); + }); + + it('should encrypt/decrypt proxy data (object)', () => { + const data = { username: 'unittest', email: 'email@mail.com' }; + const enc = encryptionService.encryptProxy(data); + expect(enc).toBeTruthy(); + const dec = encryptionService.decryptProxy(enc); + expect(dec).toBeTruthy(); + expect(data).toMatchObject(JSON.parse(dec)); + }); + + it('should encrypt/decrypt proxy data (string)', () => { + const data = 'this is my string value'; + const enc = encryptionService.encryptProxy(data); + expect(enc).toBeTruthy(); + const dec = encryptionService.decryptProxy(enc); + expect(dec).toBeTruthy(); + expect(data).toEqual(dec); + }); + + it('should encrypt/decrypt db data (object)', () => { + const data = { username: 'unittest', email: 'email@mail.com' }; + const enc = encryptionService.encryptDb(data); + expect(enc).toBeTruthy(); + const dec = encryptionService.decryptDb(enc); + expect(dec).toBeTruthy(); + expect(data).toMatchObject(JSON.parse(dec)); + }); + + it('should encrypt/decrypt db data (string)', () => { + const data = 'this is my string value'; + const enc = encryptionService.encryptDb(data); + expect(enc).toBeTruthy(); + const dec = encryptionService.decryptDb(enc); + expect(dec).toBeTruthy(); + expect(data).toEqual(dec); + }); + + it('should not decrypt a proxy encryption with db key', () => { + const data = 'this is my string value'; + const enc = encryptionService.encryptProxy(data); + expect(enc).toBeTruthy(); + expect(() => { + encryptionService.decryptDb(enc); + }).toThrowError(); + }); + + it('should throw error with unknown algorithm name', () => { + const data = 'this is my string value'; + expect(() => { + encryptionService.encrypt('unknown-algorithm-name', ENCRYPTION_KEYS.PROXY, data); + }).toThrowError(); + }); + + it('should throw error with unknown key name', () => { + const data = 'this is my string value'; + expect(() => { + encryptionService.encrypt(ENCRYPTION_ALGORITHMS.AES_256_GCM, 'unknown-key-name', data); + }).toThrowError(); + }); + + it('should encrypt/decrypt data (object) using external key', () => { + const externalKey = 'e9eb43121581f1877e2b8135c8d9079b91c04aab6c717799196630a685b2c6c0'; + const data = { username: 'unittest', email: 'email@mail.com' }; + const enc = encryptionService.encryptExternal(ENCRYPTION_ALGORITHMS.AES_256_GCM, externalKey, data); + expect(enc).toBeTruthy(); + const dec = encryptionService.decryptExternal(ENCRYPTION_ALGORITHMS.AES_256_GCM, externalKey, enc); + expect(dec).toBeTruthy(); + expect(data).toMatchObject(JSON.parse(dec)); + }); + + it('should encrypt/decrypt data (string) using external key', () => { + const externalKey = 'b93476a2446a0bad7cdbe8443aee8c2b08c0a482bfadce23ebed97435b25401f'; + const data = 'this is my string value'; + const enc = encryptionService.encryptExternal(ENCRYPTION_ALGORITHMS.AES_256_GCM, externalKey, data); + expect(enc).toBeTruthy(); + const dec = encryptionService.decryptExternal(ENCRYPTION_ALGORITHMS.AES_256_GCM, externalKey, enc); + expect(dec).toBeTruthy(); + expect(data).toEqual(dec); + }); + + it('should throw error no payload', () => { + const data = undefined; + expect(() => { + encryptionService.encrypt(ENCRYPTION_ALGORITHMS.AES_256_GCM, ENCRYPTION_KEYS.PROXY, data); + }).toThrowError(); + }); +}); diff --git a/app/tests/unit/forms/proxy/controller.spec.js b/app/tests/unit/forms/proxy/controller.spec.js new file mode 100644 index 000000000..fb1818d58 --- /dev/null +++ b/app/tests/unit/forms/proxy/controller.spec.js @@ -0,0 +1,124 @@ +const axios = require('axios'); +const { getMockReq, getMockRes } = require('@jest-mock/express'); + +const controller = require('../../../../src/forms/proxy/controller'); +const service = require('../../../../src/forms/proxy/service'); +const jwtService = require('../../../../src/components/jwtService'); + +const bearerToken = Math.random().toString(36).substring(2); + +jwtService.validateAccessToken = jest.fn().mockReturnValue(true); +jwtService.getBearerToken = jest.fn().mockReturnValue(bearerToken); +jwtService.getTokenPayload = jest.fn().mockReturnValue({ token: 'payload' }); + +//const mockInstance = axios.create(); +//const mockAxios = new MockAdapter(mockInstance); +// Replace any instances with the mocked instance (a new mock could be used here instead): +jest.mock('axios'); +axios.create.mockImplementation(() => axios); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('generateProxyHeaders', () => { + const req = { + params: {}, + body: { formId: '1234' }, + currentUser: { idpUserId: '123456789', username: 'TMCTEST', firstName: 'Test', lastName: 'McTest', fullName: 'Test McTest', email: 'test.mctest@gov.bc.ca', idp: 'idir' }, + headers: { referer: 'a' }, + }; + + it('should generate headers', async () => { + service.generateProxyHeaders = jest.fn().mockReturnValue({}); + + await controller.generateProxyHeaders(req, {}, jest.fn()); + expect(service.generateProxyHeaders).toBeCalledTimes(1); + expect(jwtService.getBearerToken).toBeCalledTimes(1); + }); +}); + +describe('callExternalApi', () => { + it('should call external api', async () => { + const req = getMockReq({ headers: { 'X-CHEFS-PROXY-DATA': 'encrypted blob of proxy data' } }); + const { res, next } = getMockRes(); + service.readProxyHeaders = jest.fn().mockReturnValue({}); + service.getExternalAPI = jest.fn().mockReturnValue({}); + service.createExternalAPIUrl = jest.fn().mockReturnValue('http://external.api'); + service.createExternalAPIHeaders = jest.fn().mockReturnValue({ 'X-TEST-HEADERS': 'test-headers' }); + + const mockResponse = { + data: [{ name: 'a', value: 'A' }], + status: 200, + }; + axios.get.mockResolvedValueOnce(mockResponse); + + await controller.callExternalApi(req, res, next); + + expect(service.readProxyHeaders).toBeCalledTimes(1); + expect(service.getExternalAPI).toBeCalledTimes(1); + expect(service.createExternalAPIUrl).toBeCalledTimes(1); + expect(service.createExternalAPIHeaders).toBeCalledTimes(1); + expect(res.status).toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('should call next when external api returns 401', async () => { + const req = getMockReq({ headers: { 'X-CHEFS-PROXY-DATA': 'encrypted blob of proxy data' } }); + const { res, next } = getMockRes(); + + const mockResponse = { + data: {}, + status: 401, + statusText: 'Unauthorized', + headers: {}, + config: {}, + response: { + data: { errors: [{ detail: 'a' }] }, + }, + }; + axios.get.mockRejectedValueOnce(mockResponse); + + service.readProxyHeaders = jest.fn().mockReturnValue({}); + service.getExternalAPI = jest.fn().mockReturnValue({}); + service.createExternalAPIUrl = jest.fn().mockReturnValue('http://external.api/private'); + service.createExternalAPIHeaders = jest.fn().mockReturnValue({ 'X-TEST-HEADERS': 'test-headers-err' }); + + await controller.callExternalApi(req, res, next); + + expect(service.readProxyHeaders).toBeCalledTimes(1); + expect(service.getExternalAPI).toBeCalledTimes(1); + expect(service.createExternalAPIUrl).toBeCalledTimes(1); + expect(service.createExternalAPIHeaders).toBeCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + expect(next).toBeCalledTimes(1); + }); + + it('should call next when external api returns 500', async () => { + const req = getMockReq({ headers: { 'X-CHEFS-PROXY-DATA': 'encrypted blob of proxy data' } }); + const { res, next } = getMockRes(); + + const mockResponse = { + data: {}, + status: 500, + response: { + data: { errors: [{ detail: 'server error' }] }, + }, + }; + axios.get.mockRejectedValueOnce(mockResponse); + + service.readProxyHeaders = jest.fn().mockReturnValue({}); + service.getExternalAPI = jest.fn().mockReturnValue({}); + service.createExternalAPIUrl = jest.fn().mockReturnValue('http://external.api/private'); + service.createExternalAPIHeaders = jest.fn().mockReturnValue({ 'X-TEST-HEADERS': 'test-headers-err' }); + + await controller.callExternalApi(req, res, next); + + expect(service.readProxyHeaders).toBeCalledTimes(1); + expect(service.getExternalAPI).toBeCalledTimes(1); + expect(service.createExternalAPIUrl).toBeCalledTimes(1); + expect(service.createExternalAPIHeaders).toBeCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + expect(next).toBeCalledTimes(1); + }); +}); diff --git a/app/tests/unit/forms/proxy/routes.spec.js b/app/tests/unit/forms/proxy/routes.spec.js new file mode 100644 index 000000000..ec5340a8b --- /dev/null +++ b/app/tests/unit/forms/proxy/routes.spec.js @@ -0,0 +1,149 @@ +jest.mock('cors', () => + jest.fn(() => { + return jest.fn((_req, _res, next) => { + next(); + }); + }) +); + +const request = require('supertest'); +const Problem = require('api-problem'); +const { expressHelper } = require('../../../common/helper'); +const apiAccess = require('../../../../src/forms/auth/middleware/apiAccess'); +const userAccess = require('../../../../src/forms/auth/middleware/userAccess'); +const rateLimiter = require('../../../../src/forms/common/middleware/rateLimiter'); + +// +// Mock out all the middleware - we're testing that the routes are set up +// correctly, not the functionality of the middleware. +// +const jwtService = require('../../../../src/components/jwtService'); + +// +// test assumes that caller has appropriate token, we are not testing middleware here... +// +jwtService.protect = jest.fn(() => { + return jest.fn((_req, _res, next) => { + next(); + }); +}); + +jest.mock('../../../../src/forms/auth/middleware/apiAccess'); +apiAccess.mockImplementation( + jest.fn((_req, _res, next) => { + next(); + }) +); + +rateLimiter.apiKeyRateLimiter = jest.fn((_req, _res, next) => { + next(); +}); + +userAccess.currentUser = jest.fn((_req, _res, next) => { + next(); +}); + +const service = require('../../../../src/forms/proxy/service'); + +// +// Create the router and a simple Express server. +// + +const router = require('../../../../src/forms/proxy/routes'); + +const basePath = '/proxy'; +const app = expressHelper(basePath, router); +const appRequest = request(app); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe(`${basePath}/external`, () => { + const path = `${basePath}/external`; + + describe('POST', () => { + it('should return 404', async () => { + const response = await appRequest.post(path); + + expect(response.statusCode).toBe(404); + }); + }); + + describe('GET', () => { + it('should handle 401', async () => { + service.readProxyHeaders = jest.fn().mockRejectedValue(new Problem('401')); + + const response = await appRequest.get(path); + + expect(response.statusCode).toBe(401); + }); + + it('should handle 500', async () => { + service.readProxyHeaders = jest.fn().mockRejectedValue(new Problem('500')); + + const response = await appRequest.get(path); + + expect(response.statusCode).toBe(500); + }); + + describe('GET', () => { + it('should return 200', async () => { + const response = await appRequest.get(path).expect(function (res) { + res.statusCode = 200; + res.body = {}; + }); + + expect(response.statusCode).toBe(200); + }); + }); + }); +}); + +describe(`${basePath}/headers`, () => { + const path = `${basePath}/headers`; + + describe('GET', () => { + it('should return 404', async () => { + const response = await appRequest.get(path); + + expect(response.statusCode).toBe(404); + }); + }); + + describe('POST', () => { + it('should handle 401', async () => { + service.generateProxyHeaders = jest.fn(() => { + throw new Problem(401); + }); + + const response = await appRequest.post(path); + + expect(response.statusCode).toBe(401); + }); + }); + + describe('GET', () => { + it('should handle 500', async () => { + service.generateProxyHeaders = jest.fn(() => { + throw new Problem(500); + }); + + const response = await appRequest.post(path); + + expect(response.statusCode).toBe(500); + }); + }); + + describe('GET', () => { + it('should return 200', async () => { + service.generateProxyHeaders = jest.fn(() => { + return { 'X-HEADERS': 'encrypted blob' }; + }); + + const response = await appRequest.post(path); + + expect(response.statusCode).toBe(200); + }); + }); +}); diff --git a/app/tests/unit/forms/proxy/service.spec.js b/app/tests/unit/forms/proxy/service.spec.js new file mode 100644 index 000000000..9ea783c36 --- /dev/null +++ b/app/tests/unit/forms/proxy/service.spec.js @@ -0,0 +1,302 @@ +const { MockModel, MockTransaction } = require('../../../common/dbHelper'); + +const { v4: uuidv4 } = require('uuid'); + +const { encryptionService, ENCRYPTION_ALGORITHMS } = require('../../../../src/components/encryptionService'); +const service = require('../../../../src/forms/proxy/service'); +const { ExternalAPI } = require('../../../../src/forms/common/models'); + +const goodPayload = { + formId: '123', + submissionId: '456', + versionId: '789', +}; + +const goodCurrentUser = { + idpUserId: '123456789', + username: 'TMCTEST', + firstName: 'Test', + lastName: 'McTest', + fullName: 'Test McTest', + email: 'test.mctest@gov.bc.ca', + idp: 'idir', +}; + +const token = 'token!'; + +const goodProxyHeaderInfo = { + ...goodPayload, + ...goodCurrentUser, + userId: goodCurrentUser.idpUserId, + token: token, +}; +delete goodProxyHeaderInfo.idpUserId; + +const goodExternalApi = { + id: uuidv4(), + formId: uuidv4(), + name: 'test_api', + endpointUrl: 'http://external.api/', + sendApiKey: true, + apiKeyHeader: 'X-API-KEY', + apiKey: 'my-api-key', + sendUserToken: true, + userTokenHeader: 'Authorization', + userTokenBearer: true, + sendUserInfo: true, + userInfoHeader: 'X-API-USER', + userInfoEncrypted: true, + userInfoEncryptionKey: '0489aa2a7882dc53be7c76db43be1800e56627c31a88a0011d85ccc255b79d00', + userInfoEncryptionAlgo: ENCRYPTION_ALGORITHMS.AES_256_GCM, +}; + +beforeEach(() => { + MockModel.mockReset(); + MockTransaction.mockReset(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('Proxy Service', () => { + describe('generateProxyHeaders', () => { + beforeEach(() => {}); + + it('should throw error with no payload', async () => { + await expect(service.generateProxyHeaders(undefined, goodCurrentUser, token)).rejects.toThrow(); + }); + it('should throw error with no current user', async () => { + await expect(service.generateProxyHeaders(goodPayload, undefined, token)).rejects.toThrow(); + }); + it('should throw error with invalid current user', async () => { + await expect(service.generateProxyHeaders(goodPayload, {}, token)).rejects.toThrow(); + }); + it('should return headers with no token', async () => { + const result = await service.generateProxyHeaders(goodPayload, goodCurrentUser, undefined); + expect(result).toBeTruthy(); + expect(result['X-CHEFS-PROXY-DATA']).toBeTruthy(); + }); + it('should return headers encrypted by proxy key', async () => { + const result = await service.generateProxyHeaders(goodPayload, goodCurrentUser, token); + expect(result).toBeTruthy(); + expect(result['X-CHEFS-PROXY-DATA']).toBeTruthy(); + // check the encryption... + const decrypted = encryptionService.decryptProxy(result['X-CHEFS-PROXY-DATA']); + expect(JSON.parse(decrypted)).toMatchObject(goodProxyHeaderInfo); + }); + }); + describe('readProxyHeaders', () => { + beforeEach(() => {}); + + it('should throw error with no headers', async () => { + await expect(service.readProxyHeaders(undefined)).rejects.toThrow(); + }); + + it('should throw error with wrong header name', async () => { + await expect(service.readProxyHeaders({ 'X-CHEFS-WRONG_HEADER_NAME': 'headers' })).rejects.toThrow(); + }); + + it('should throw error if payload not encrypted', async () => { + await expect(service.readProxyHeaders({ 'X-CHEFS-PROXY-DATA': 'headers' })).rejects.toThrow(); + }); + + it('should throw error if payload uses non-proxy encryption', async () => { + const data = encryptionService.encryptDb(goodProxyHeaderInfo); + await expect(service.readProxyHeaders({ 'X-CHEFS-PROXY-DATA': data })).rejects.toThrow(); + }); + + it('should return decrypted header data', async () => { + const headers = await service.generateProxyHeaders(goodPayload, goodCurrentUser, token); + const decrypted = await service.readProxyHeaders(headers); + expect(decrypted).toBeTruthy(); + expect(goodProxyHeaderInfo).toMatchObject(decrypted); + }); + }); + describe('getExternalAPI', () => { + let returnValue = null; + beforeEach(() => { + returnValue = null; + ExternalAPI.mockReturnValue = (value) => { + returnValue = value; + }; + ExternalAPI.query = jest.fn().mockReturnThis(); + ExternalAPI.where = jest.fn().mockReturnThis(); + ExternalAPI.modify = jest.fn().mockReturnThis(); + ExternalAPI.first = jest.fn().mockReturnThis(); + ExternalAPI.throwIfNotFound = jest.fn().mockReturnThis(); + ExternalAPI.then = jest.fn((done) => { + done(returnValue); + }); + }); + + it('should throw error with no headers', async () => { + // set the external api name... + const headers = undefined; + await expect(service.getExternalAPI(headers, goodProxyHeaderInfo)).rejects.toThrow(); + }); + + it('should throw error with no proxy header info', async () => { + // set the external api name... + const headers = { 'X-CHEFS-EXTERNAL-API-NAME': 'testapi' }; + await expect(service.getExternalAPI(headers, undefined)).rejects.toThrow(); + }); + + it('should throw error if api name not found', async () => { + // set the external api name... + const headers = { 'X-CHEFS-EXTERNAL-API-NAME': 'notfound' }; + ExternalAPI.throwIfNotFound = jest.fn().mockRejectedValue(new Error('not found')); + await expect(service.getExternalAPI(headers, goodProxyHeaderInfo)).rejects.toThrow(); + }); + + it('should return data with correct parameters', async () => { + // set the external api name... + const headers = { 'X-CHEFS-EXTERNAL-API-NAME': 'testapi' }; + returnValue = {}; //pretend this is an actual ExternalAPI record/object + const result = await service.getExternalAPI(headers, goodProxyHeaderInfo); + await expect(result).toBe(returnValue); + }); + }); + describe('createExternalAPIUrl', () => { + it('should throw error with no headers', async () => { + const headers = undefined; + const endpointUrl = 'http://external.api/'; // comes from ExternalAPI record + expect(() => service.createExternalAPIUrl(headers, endpointUrl)).toThrow(); + }); + + it('should throw error with no external api url', async () => { + const headers = { 'X-CHEFS-EXTERNAL-API-PATH': '/api/v1/testapi' }; + expect(() => service.createExternalAPIUrl(headers, undefined)).toThrow(); + }); + + it('should return endpointUrl if no path header', async () => { + const headers = {}; + const endpointUrl = 'http://external.api/'; // comes from ExternalAPI record + const result = service.createExternalAPIUrl(headers, endpointUrl); + await expect(result).toBe(endpointUrl); + }); + + it('should append path to endpointUrl', async () => { + const headers = { 'X-CHEFS-EXTERNAL-API-PATH': '/api/v1/testapi' }; + const endpointUrl = 'http://external.api'; // comes from ExternalAPI record + const result = service.createExternalAPIUrl(headers, endpointUrl); + await expect(result).toBe(`${endpointUrl}/api/v1/testapi`); + }); + + it('should handle leading and trailing slashes when building result', async () => { + // have both trailing / on url and leading / on path + const headers = { 'X-CHEFS-EXTERNAL-API-PATH': '/api/v1/testapi' }; + const endpointUrl = 'http://external.api/'; // comes from ExternalAPI record + const result = service.createExternalAPIUrl(headers, endpointUrl); + // only one slash between url and path + await expect(result).toBe('http://external.api/api/v1/testapi'); + }); + }); + describe('createExternalAPIHeaders', () => { + beforeEach(() => {}); + + it('should throw error with no headers', async () => { + const externalAPI = undefined; + const proxyHeaderInfo = goodProxyHeaderInfo; + expect(() => service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo)).toThrow(); + }); + it('should throw error with no current user and sending user information', async () => { + const externalAPI = goodExternalApi; + const proxyHeaderInfo = undefined; + expect(() => service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo)).toThrow(); + }); + it('should throw error with invalid current user and sending user information', async () => { + const externalAPI = goodExternalApi; + const proxyHeaderInfo = {}; + expect(() => service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo)).toThrow(); + }); + it('should NOT throw error with no current user and not sending user information', async () => { + const externalAPI = Object.assign({}, goodExternalApi); + externalAPI.sendUserToken = false; + externalAPI.sendUserInfo = false; + const proxyHeaderInfo = undefined; + const result = service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo); + expect(result).toBeTruthy(); + }); + it('should NOT throw error with invalid current user and not sending user information', async () => { + const externalAPI = Object.assign({}, goodExternalApi); + externalAPI.sendUserToken = false; + externalAPI.sendUserInfo = false; + const proxyHeaderInfo = {}; + const result = service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo); + expect(result).toBeTruthy(); + }); + it('should return populated headers', async () => { + const externalAPI = Object.assign({}, goodExternalApi); + const proxyHeaderInfo = Object.assign({}, goodProxyHeaderInfo); + const result = service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo); + expect(result).toBeTruthy(); + // with the defaul external API config we should have headers for... + // api key + expect(result[externalAPI.apiKeyHeader]).toBe(externalAPI.apiKey); + // user token (with Bearer) + expect(result[externalAPI.userTokenHeader]).toBe(`Bearer ${token}`); + // user info (encrypted) + expect(result[externalAPI.userInfoHeader]).toBeTruthy(); + const decrypted = encryptionService.decryptExternal(externalAPI.userInfoEncryptionAlgo, externalAPI.userInfoEncryptionKey, result[externalAPI.userInfoHeader]); + expect(JSON.parse(decrypted)).toMatchObject(proxyHeaderInfo); + // but no unencrypted user info headers + expect(result['X-CHEFS-USER-EMAIL']).toBeFalsy(); + }); + it('should return only api key headers', async () => { + const externalAPI = Object.assign({}, goodExternalApi); + externalAPI.sendApiKey = true; + externalAPI.sendUserToken = false; + externalAPI.sendUserInfo = false; + const proxyHeaderInfo = Object.assign({}, goodProxyHeaderInfo); + const result = service.createExternalAPIHeaders(externalAPI, proxyHeaderInfo); + expect(result).toBeTruthy(); + // api key + expect(result[externalAPI.apiKeyHeader]).toBe(externalAPI.apiKey); + // no user token + expect(result[externalAPI.userTokenHeader]).toBeFalsy(); + // no user info (encrypted) + expect(result[externalAPI.userInfoHeader]).toBeFalsy(); + // no unencrypted user info headers + expect(result['X-CHEFS-USER-EMAIL']).toBeFalsy(); + }); + it('should return only user token header (no bearer)', async () => { + const externalAPI = Object.assign({}, goodExternalApi); + externalAPI.sendApiKey = false; + externalAPI.sendUserToken = true; + externalAPI.sendUserInfo = false; + externalAPI.userTokenBearer = false; + const userInfo = Object.assign({}, goodProxyHeaderInfo); + const result = service.createExternalAPIHeaders(externalAPI, userInfo); + expect(result).toBeTruthy(); + // no api key + expect(result[externalAPI.apiKeyHeader]).toBeFalsy(); + // user token (NO Bearer) + expect(result[externalAPI.userTokenHeader]).toBe(token); + // no user info (encrypted) + expect(result[externalAPI.userInfoHeader]).toBeFalsy(); + // no unencrypted user info headers + expect(result['X-CHEFS-USER-EMAIL']).toBeFalsy(); + }); + it('should return only unencrypted user info headers', async () => { + const externalAPI = Object.assign({}, goodExternalApi); + externalAPI.sendApiKey = false; + externalAPI.sendUserToken = false; + externalAPI.sendUserInfo = true; + externalAPI.userInfoEncrypted = false; + const userInfo = Object.assign({}, goodProxyHeaderInfo); + const result = service.createExternalAPIHeaders(externalAPI, userInfo); + expect(result).toBeTruthy(); + // with the defaul external API config we should have headers for... + // no api key + expect(result[externalAPI.apiKeyHeader]).toBeFalsy(); + // no user token (with Bearer) + expect(result[externalAPI.userTokenHeader]).toBeFalsy(); + // no user info (encrypted) + expect(result[externalAPI.userInfoHeader]).toBeFalsy(); + // unencrypted user info headers + expect(result['X-CHEFS-USER-EMAIL']).toBe(userInfo.email); + expect(result['X-CHEFS-FORM-FORMID']).toBe(userInfo.formId); + }); + }); +}); diff --git a/app/tests/unit/routes/v1.spec.js b/app/tests/unit/routes/v1.spec.js index 20c5f99d9..ff5bef609 100755 --- a/app/tests/unit/routes/v1.spec.js +++ b/app/tests/unit/routes/v1.spec.js @@ -17,7 +17,7 @@ describe(`${basePath}`, () => { expect(response.statusCode).toBe(200); expect(response.body).toBeTruthy(); expect(Array.isArray(response.body.endpoints)).toBeTruthy(); - expect(response.body.endpoints).toHaveLength(11); + expect(response.body.endpoints).toHaveLength(12); expect(response.body.endpoints).toContain('/docs'); expect(response.body.endpoints).toContain('/files'); expect(response.body.endpoints).toContain('/forms');