diff --git a/api/src/db.js b/api/src/db.js index 4546b4ed211..e9bdab6b643 100644 --- a/api/src/db.js +++ b/api/src/db.js @@ -6,6 +6,8 @@ PouchDB.plugin(require('pouchdb-adapter-http')); PouchDB.plugin(require('pouchdb-session-authentication')); PouchDB.plugin(require('pouchdb-find')); PouchDB.plugin(require('pouchdb-mapreduce')); +const asyncLocalStorage = require('./services/async-storage'); +const { REQUEST_ID_HEADER } = require('./server-utils'); const { UNIT_TEST_ENV } = process.env; @@ -74,6 +76,10 @@ if (UNIT_TEST_ENV) { const fetch = (url, opts) => { // Adding audit flag (haproxy) Service that made the request initially. opts.headers.set('X-Medic-Service', 'api'); + const requestId = asyncLocalStorage.getRequestId(); + if (requestId) { + opts.headers.set(REQUEST_ID_HEADER, requestId); + } return PouchDB.fetch(url, opts); }; diff --git a/api/src/routing.js b/api/src/routing.js index f027f1ddb03..6f1ce353a56 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -77,6 +77,7 @@ const dbDocHandler = require('./controllers/db-doc'); const extensionLibs = require('./controllers/extension-libs'); const replication = require('./controllers/replication'); const app = express.Router({ strict: true }); +const asyncLocalStorage = require('./services/async-storage'); const moment = require('moment'); const MAX_REQUEST_SIZE = '32mb'; @@ -156,9 +157,15 @@ if (process.argv.slice(2).includes('--allow-cors')) { }); } +const shortUuid = () => { + const ID_LENGTH = 12; + return uuid.v4().replace(/-/g, '').toLowerCase().slice(0, ID_LENGTH); +}; + app.use((req, res, next) => { - req.id = uuid.v4(); - next(); + req.id = shortUuid(); + req.headers[serverUtils.REQUEST_ID_HEADER] = req.id; + asyncLocalStorage.set(req, () => next()); }); app.use(getLocale); diff --git a/api/src/server-utils.js b/api/src/server-utils.js index c642fbffb4b..d9f7d48c442 100644 --- a/api/src/server-utils.js +++ b/api/src/server-utils.js @@ -4,6 +4,7 @@ const environment = require('@medic/environment'); const isClientHuman = require('./is-client-human'); const logger = require('@medic/logger'); const MEDIC_BASIC_AUTH = 'Basic realm="Medic Web Services"'; +const REQUEST_ID_HEADER = 'X-Request-Id'; const cookie = require('./services/cookie'); const {InvalidArgumentError} = require('@medic/cht-datasource'); @@ -49,6 +50,7 @@ const promptForBasicAuth = res => { module.exports = { MEDIC_BASIC_AUTH: MEDIC_BASIC_AUTH, + REQUEST_ID_HEADER: REQUEST_ID_HEADER, /* * Attempts to determine the correct response given the error code. diff --git a/api/src/services/async-storage.js b/api/src/services/async-storage.js new file mode 100644 index 00000000000..dca33a84b4b --- /dev/null +++ b/api/src/services/async-storage.js @@ -0,0 +1,17 @@ +const { AsyncLocalStorage } = require('node:async_hooks'); +const asyncLocalStorage = new AsyncLocalStorage(); +const { REQUEST_ID_HEADER } = require('../server-utils'); + +const request = require('@medic/couch-request'); + +module.exports = { + set: (req, callback) => { + asyncLocalStorage.run({ clientRequest: req }, callback); + }, + getRequestId: () => { + const localStorage = asyncLocalStorage.getStore(); + return localStorage?.clientRequest?.id; + }, +}; + +request.initialize(module.exports, REQUEST_ID_HEADER); diff --git a/api/tests/mocha/db.spec.js b/api/tests/mocha/db.spec.js index 9922e38d813..df4ba940385 100644 --- a/api/tests/mocha/db.spec.js +++ b/api/tests/mocha/db.spec.js @@ -1,5 +1,6 @@ const sinon = require('sinon'); require('chai').use(require('chai-as-promised')); +const PouchDB = require('pouchdb-core'); const { expect } = require('chai'); const rewire = require('rewire'); const request = require('@medic/couch-request'); @@ -8,6 +9,7 @@ let db; let unitTestEnv; const env = require('@medic/environment'); +const asyncLocalStorage = require('../../src/services/async-storage'); describe('db', () => { beforeEach(() => { @@ -404,4 +406,39 @@ describe('db', () => { .to.be.rejectedWith(Error, `Cannot add security: invalid db name dbanme or role`); }); }); + + describe('fetch extension', () => { + it('should set headers where there is an active client request', async () => { + sinon.stub(PouchDB, 'fetch').resolves({ + json: sinon.stub().resolves({ result: true }), + ok: true, + }); + sinon.stub(asyncLocalStorage, 'getRequestId').returns('the_id'); + db = rewire('../../src/db'); + + await db.medic.info(); + const headers = PouchDB.fetch.args.map(arg => arg[1].headers); + expect(headers.length).to.equal(4); + headers.forEach((header) => { + expect(header.get('X-Medic-Service')).to.equal('api'); + expect(header.get('X-Request-Id')).to.equal('the_id'); + }); + }); + + it('should work when call is made without an active clinet request', async () => { + sinon.stub(PouchDB, 'fetch').resolves({ + json: sinon.stub().resolves({ result: true }), + ok: true, + }); + sinon.stub(asyncLocalStorage, 'getRequestId').returns(undefined); + db = rewire('../../src/db'); + + await db.medic.info(); + const headers = PouchDB.fetch.args.map(arg => arg[1].headers); + expect(headers.length).to.equal(4); + headers.forEach((header) => { + expect(header.get('X-Medic-Service')).to.equal('api'); + }); + }); + }); }); diff --git a/api/tests/mocha/server-utils.spec.js b/api/tests/mocha/server-utils.spec.js index c5665acdbf1..8618ab0de20 100644 --- a/api/tests/mocha/server-utils.spec.js +++ b/api/tests/mocha/server-utils.spec.js @@ -282,4 +282,8 @@ describe('Server utils', () => { chai.expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; }); }); + + it('should export request header', () => { + chai.expect(serverUtils.REQUEST_ID_HEADER).to.equal('X-Request-Id'); + }); }); diff --git a/api/tests/mocha/services/async-storage.spec.js b/api/tests/mocha/services/async-storage.spec.js new file mode 100644 index 00000000000..cc77549ed17 --- /dev/null +++ b/api/tests/mocha/services/async-storage.spec.js @@ -0,0 +1,67 @@ +const sinon = require('sinon'); +const rewire = require('rewire'); +const { expect } = require('chai'); +const asyncHooks = require('node:async_hooks'); +const request = require('@medic/couch-request'); +const serverUtils = require('../../../src/server-utils'); + +describe('async-storage', () => { + let service; + let asyncLocalStorage; + + beforeEach(() => { + asyncLocalStorage = sinon.spy(asyncHooks, 'AsyncLocalStorage'); + sinon.stub(request, 'initialize'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should initialize async storage and initialize couch-request', async () => { + service = rewire('../../../src/services/async-storage'); + + expect(asyncLocalStorage.callCount).to.equal(1); + expect(request.initialize.args).to.deep.equal([[ + service, + serverUtils.REQUEST_ID_HEADER + ]]); + }); + + it('set should set request uuid', () => { + service = rewire('../../../src/services/async-storage'); + const asyncLocalStorage = service.__get__('asyncLocalStorage'); + sinon.stub(asyncLocalStorage, 'run'); + + const req = { this: 'is a req' }; + const cb = sinon.stub(); + Object.freeze(req); + service.set(req, cb); + expect(asyncLocalStorage.run.args).to.deep.equal([[ + { clientRequest: req }, + cb + ]]); + }); + + it('getRequestId should return request id when set', done => { + service = rewire('../../../src/services/async-storage'); + const req = { id: 'uuid' }; + service.set(req, () => { + expect(service.getRequestId()).to.equal('uuid'); + done(); + }); + }); + + it('getRequestId should return nothing when there is no local storage', () => { + service = rewire('../../../src/services/async-storage'); + expect(service.getRequestId()).to.equal(undefined); + }); + + it('getRequestId should return nothing when there is no client request', done => { + service = rewire('../../../src/services/async-storage'); + service.set(undefined, () => { + expect(service.getRequestId()).to.equal(undefined); + done(); + }); + }); +}); diff --git a/haproxy/default_frontend.cfg b/haproxy/default_frontend.cfg index e77a7ec6073..2d47f4a1f19 100644 --- a/haproxy/default_frontend.cfg +++ b/haproxy/default_frontend.cfg @@ -63,10 +63,11 @@ frontend http-in http-request capture req.hdr(x-medic-service) len 200 # capture.req.hdr(1) http-request capture req.hdr(x-medic-user) len 200 # capture.req.hdr(2) http-request capture req.hdr(user-agent) len 600 # capture.req.hdr(3) + http-request capture req.hdr(x-request-id) len 12 # capture.req.hdr(4) capture response header Content-Length len 10 # capture.res.hdr(0) http-response set-header Connection Keep-Alive http-response set-header Keep-Alive timeout=18000 log global - log-format "%ci,%s,%ST,%Ta,%Ti,%TR,%[capture.req.method],%[capture.req.uri],%[capture.req.hdr(1)],%[capture.req.hdr(2)],'%[capture.req.hdr(0),lua.replacePassword]',%B,%Tr,%[capture.res.hdr(0)],'%[capture.req.hdr(3)]'" + log-format "%ci,%s,%ST,%Ta,%Ti,%TR,%[capture.req.method],%[capture.req.uri],%[capture.req.hdr(1)],%[capture.req.hdr(2)],%[capture.req.hdr(4)],'%[capture.req.hdr(0),lua.replacePassword]',%B,%Tr,%[capture.res.hdr(0)],'%[capture.req.hdr(3)]'" default_backend couchdb-servers diff --git a/shared-libs/couch-request/src/couch-request.js b/shared-libs/couch-request/src/couch-request.js index 53867c5979c..c33b8eac7f2 100644 --- a/shared-libs/couch-request/src/couch-request.js +++ b/shared-libs/couch-request/src/couch-request.js @@ -2,6 +2,9 @@ const request = require('request-promise-native'); const isPlainObject = require('lodash/isPlainObject'); const environment = require('@medic/environment'); const servername = environment.host; +let asyncLocalStorage; +let requestIdHeader; + const isString = value => typeof value === 'string' || value instanceof String; const isTrue = value => isString(value) ? value.toLowerCase() === 'true' : value === true; @@ -19,11 +22,17 @@ const methods = { const mergeOptions = (target, source, exclusions = []) => { for (const [key, value] of Object.entries(source)) { if (Array.isArray(exclusions) && exclusions.includes(key)) { - return target; + continue; } target[key] = value; // locally, mutation is preferable to spreading as it doesn't // make new objects in memory. Assuming this is a hot path. } + const requestId = asyncLocalStorage?.getRequestId(); + if (requestId) { + target.headers = target.headers || {}; + target.headers[requestIdHeader] = requestId; + } + return target; }; @@ -104,6 +113,11 @@ const getRequestType = (method) => { }; module.exports = { + initialize: (store, header) => { + asyncLocalStorage = store; + requestIdHeader = header; + }, + get: (first, second = {}) => req(methods.GET, first, second), post: (first, second = {}) => req(methods.POST, first, second), put: (first, second = {}) => req(methods.PUT, first, second), diff --git a/shared-libs/couch-request/test/couch-request.js b/shared-libs/couch-request/test/couch-request.js index ac78e3da5a7..48cc18ea4e7 100644 --- a/shared-libs/couch-request/test/couch-request.js +++ b/shared-libs/couch-request/test/couch-request.js @@ -1,20 +1,40 @@ - const chai = require('chai').use(require('chai-as-promised')); const request = require('request-promise-native'); const sinon = require('sinon'); const rewire = require('rewire'); +chai.config.truncateThreshold = 0; class Unicorn { } -const notPlainObjects = [[1, 2, 3], new Unicorn(), new Map([[1, 1], [2, 2], [3, 3]]), new Set([1, 2, 3]), - () => { }, true, null, 1, NaN, Infinity, /foo/, new Date(), new Error(), new Int8Array(), new Float32Array(), - new Float64Array(), new Uint8Array(), new Uint8ClampedArray(), new Uint16Array(), new Uint32Array(), - new ArrayBuffer(), new WeakMap(), new WeakSet()]; +const notPlainObjects = [ + [1, 2, 3], + new Unicorn(), + new Map([[1, 1], [2, 2], [3, 3]]), + new Set([1, 2, 3]), + () => { }, + true, + null, + 1, + NaN, + Infinity, + /foo/, + new Date(), + new Error(), + new Int8Array(), + new Float32Array(), + new Float64Array(), + new Uint8Array(), + new Uint8ClampedArray(), + new Uint16Array(), + new Uint32Array(), + new ArrayBuffer(), + new WeakMap(), + new WeakSet() +]; const notPlainObjectsWithString = notPlainObjects.concat(['foo']); const optionsErrorMsg = '"options" must be a plain object'; describe('Couch request rejects non-plain objects', () => { - - let couch_request; + let couchRequest; before(() => { sinon.stub(process, 'env').value({ @@ -22,109 +42,144 @@ describe('Couch request rejects non-plain objects', () => { COUCH_URL: 'http://admin:password@test.com:5984/medic', ADD_SERVERNAME_TO_HTTP_AGENT: 'true' }); - couch_request = rewire('../src/couch-request'); + couchRequest = rewire('../src/couch-request'); }); notPlainObjectsWithString.forEach(notPlainObject => { if (typeof notPlainObject === 'undefined' || notPlainObject === null) { - it(`Rejects notPlainObject as second arg (method: get): (string, notPlainObject == null)`, done => { - chai.expect(couch_request.get('string', notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + it(`Rejects notPlainObject as second arg (method: get): (string, notPlainObject == null)`, async () => { + await chai.expect(couchRequest.get('string', notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as second arg (method: post): (notPlainObject == null)`, done => { - chai.expect(couch_request.post('string', notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as second arg (method: post): (notPlainObject == null)`, async () => { + await chai.expect(couchRequest.post('string', notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as second arg (method: put): (notPlainObject == null)`, done => { - chai.expect(couch_request.put('string', notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as second arg (method: put): (notPlainObject == null)`, async () => { + await chai.expect(couchRequest.put('string', notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as second arg (method: delete): (notPlainObject == null)`, done => { - chai.expect(couch_request.delete('string', notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as second arg (method: delete): (notPlainObject == null)`, async () => { + await chai.expect(couchRequest.delete('string', notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as second arg (method: head): (notPlainObject == null)`, done => { - chai.expect(couch_request.head('string', notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as second arg (method: head): (notPlainObject == null)`, async () => { + await chai.expect(couchRequest.head('string', notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); } else { const toString = `${notPlainObject.toString()}`; const result = toString === '' ? Object.getPrototypeOf(notPlainObject).constructor.name : toString; - it(`Rejects notPlainObject as second arg (method: get): (string, ${result})`, done => { - chai.expect(couch_request.get('string', notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as second arg (method: get): (string, ${result})`, async () => { + await chai.expect(couchRequest.get('string', notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as second arg (method: post): ${result}`, done => { - chai.expect(couch_request.post('string', notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as second arg (method: post): ${result}`, async () => { + await chai.expect(couchRequest.post('string', notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as second arg (method: put): ${result}`, done => { - chai.expect(couch_request.put('string', notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as second arg (method: put): ${result}`, async () => { + await chai.expect(couchRequest.put('string', notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as second arg (method: delete): ${result}`, done => { - chai.expect(couch_request.delete('string', notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + it(`Rejects notPlainObject as second arg (method: delete): ${result}`, async () => { + await chai.expect(couchRequest.delete('string', notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as second arg (method: head): ${result}`, done => { - chai.expect(couch_request.head('string', notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + it(`Rejects notPlainObject as second arg (method: head): ${result}`, async () => { + await chai.expect(couchRequest.head('string', notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); } }); - + notPlainObjects.forEach(notPlainObject => { if (typeof notPlainObject === 'undefined' || notPlainObject === null) { - it(`Rejects notPlainObject as first arg (method: get): (notPlainObject == null)`, done => { - chai.expect(couch_request.get(notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + it(`Rejects notPlainObject as first arg (method: get): (notPlainObject == null)`, async () => { + await chai.expect(couchRequest.get(notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as first arg (method: post): (notPlainObject == null)`, done => { - chai.expect(couch_request.post(notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as first arg (method: post): (notPlainObject == null)`, async () => { + await chai.expect(couchRequest.post(notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as first arg (method: put): (notPlainObject == null)`, done => { - chai.expect(couch_request.put(notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as first arg (method: put): (notPlainObject == null)`, async () => { + await chai.expect(couchRequest.put(notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as first arg (method: delete): (notPlainObject == null)`, done => { - chai.expect(couch_request.delete(notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as first arg (method: delete): (notPlainObject == null)`, async () => { + await chai.expect(couchRequest.delete(notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as first arg (method: head): (notPlainObject == null)`, done => { - chai.expect(couch_request.head(notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as first arg (method: head): (notPlainObject == null)`, async () => { + await chai.expect(couchRequest.head(notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); } else { const toString = `${notPlainObject.toString()}`; const result = toString === '' ? Object.getPrototypeOf(notPlainObject).constructor.name : toString; - it(`Rejects notPlainObject as first arg (method: get): (${result})`, done => { - chai.expect(couch_request.get(notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as first arg (method: get): (${result})`, async () => { + await chai.expect(couchRequest.get(notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as first arg (method: post): ${result}`, done => { - chai.expect(couch_request.post(notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as first arg (method: post): ${result}`, async () => { + await chai.expect(couchRequest.post(notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as first arg (method: put): ${result}`, done => { - chai.expect(couch_request.put(notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as first arg (method: put): ${result}`, async () => { + await chai.expect(couchRequest.put(notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as first arg (method: delete): ${result}`, done => { - chai.expect(couch_request.delete(notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as first arg (method: delete): ${result}`, async () => { + await chai.expect(couchRequest.delete(notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); - it(`Rejects notPlainObject as first arg (method: head): ${result}`, done => { - chai.expect(couch_request.head(notPlainObject)).to.eventually.be.rejectedWith(optionsErrorMsg) - .and.be.an.instanceOf(Error).notify(done); + + it(`Rejects notPlainObject as first arg (method: head): ${result}`, async () => { + await chai.expect(couchRequest.head(notPlainObject)) + .to.eventually.be.rejectedWith(optionsErrorMsg) + .and.be.an.instanceOf(Error); }); } }); }); describe('Couch request with servername added receives correct options and returns stub value', () => { - - let couch_request; + let couchRequest; before(() => { sinon.stub(process, 'env').value({ @@ -132,179 +187,361 @@ describe('Couch request with servername added receives correct options and retur COUCH_URL: 'http://admin:password@test.com:5984/medic', ADD_SERVERNAME_TO_HTTP_AGENT: 'true' }); - couch_request = rewire('../src/couch-request'); + couchRequest = rewire('../src/couch-request'); }); afterEach(() => { sinon.restore(); }); - const options = [{ - foo: 'bar', url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', - uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', - method: 'GET' // Should not be passed to options - }]; + const options = [ + { + foo: 'bar', + url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + method: 'GET' // Should not be passed to options + }, + { + foo: 'bar', + url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + method: 'GET', // Should not be passed to options + headers: { 'Accepts': 'application/json, text/plain, */*' }, + }, + ]; options.forEach(option => { - it(`Get: Called with composite options and returns 'get'`, async function () { - const requestGet = sinon.stub(request, 'get'); - requestGet.returns(Promise.resolve('get')); - const result = await couch_request.get(option); - sinon.assert.calledWith(requestGet, { - servername: 'test.com', foo: 'bar', - url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly' }); + it(`Get: Called with composite options and returns 'get'`, async () => { + sinon.stub(request, 'get').resolves('get'); + const result = await couchRequest.get(option); + + chai.expect(request.get.args).to.deep.equal([[{ + servername: 'test.com', + foo: 'bar', + url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + ...(option.headers && { headers: option.headers }), + }]]); chai.expect(result).to.equal('get'); }); - it(`Get: Called with url as first parameter and composite options and returns 'get'`, async function () { - const requestGet = sinon.stub(request, 'get'); - requestGet.returns(Promise.resolve('get')); - const result = await couch_request.get('a-test-url', option); - sinon.assert.calledWith(requestGet, 'a-test-url', { servername: 'test.com', foo: 'bar'}); + + it(`Get: Called with url as first parameter and composite options and returns 'get'`, async () => { + sinon.stub(request, 'get').resolves('get'); + const result = await couchRequest.get('a-test-url', option); + chai.expect(request.get.args).to.deep.equal([[ + 'a-test-url', + { + servername: 'test.com', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('get'); }); - it(`Post: Called with composite options and returns 'post'`, async function () { - const requestPost = sinon.stub(request, 'post'); - requestPost.returns(Promise.resolve('post')); - const result = await couch_request.post(option); - sinon.assert.calledWith(requestPost, { - servername: 'test.com', foo: 'bar', - url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly' }); + + it(`Post: Called with composite options and returns 'post'`, async () => { + sinon.stub(request, 'post').resolves('post'); + const result = await couchRequest.post(option); + + chai.expect(request.post.args).to.deep.equal([[ + { + servername: 'test.com', + foo: 'bar', + url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + ...(option.headers && { headers: option.headers }), + } + ]]); + chai.expect(result).to.equal('post'); }); - it(`Post: Called with url as first parameter and composite options and returns 'post'`, async function () { - const requestPost = sinon.stub(request, 'post'); - requestPost.returns(Promise.resolve('post')); - const result = await couch_request.post('a-test-url', option); - sinon.assert.calledWith(requestPost, 'a-test-url', { - servername: 'test.com', foo: 'bar' }); + + it(`Post: Called with url as first parameter and composite options and returns 'post'`, async () => { + sinon.stub(request, 'post').resolves('post'); + const result = await couchRequest.post('a-test-url', option); + + chai.expect(request.post.args).to.deep.equal([[ + 'a-test-url', + { + servername: 'test.com', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); + chai.expect(result).to.equal('post'); }); - it(`Head: Called with composite options and returns 'head'`, async function () { - const requestHead = sinon.stub(request, 'head'); - requestHead.returns(Promise.resolve('head')); - const result = await couch_request.head(option); - sinon.assert.calledWith(requestHead, { - servername: 'test.com', foo: 'bar', - url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly' }); + + it(`Head: Called with composite options and returns 'head'`, async () => { + sinon.stub(request, 'head').resolves('head'); + const result = await couchRequest.head(option); + + chai.expect(request.head.args).to.deep.equal([[ + { + servername: 'test.com', + foo: 'bar', + url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('head'); }); - it(`Head: Called with url as first parameter and composite options and returns 'head'`, async function () { - const requestHead = sinon.stub(request, 'head'); - requestHead.returns(Promise.resolve('head')); - const result = await couch_request.head('a-test-url', option); - sinon.assert.calledWith(requestHead, 'a-test-url', { - servername: 'test.com', foo: 'bar' }); + + it(`Head: Called with url as first parameter and composite options and returns 'head'`, async () => { + sinon.stub(request, 'head').resolves('head'); + + const result = await couchRequest.head('a-test-url', option); + + chai.expect(request.head.args).to.deep.equal([[ + 'a-test-url', + { + servername: 'test.com', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('head'); }); - it(`Delete: Called with composite options and returns 'delete'`, async function () { - const requestDelete = sinon.stub(request, 'delete'); - requestDelete.returns(Promise.resolve('delete')); - const result = await couch_request.delete(option); - sinon.assert.calledWith(requestDelete, { - servername: 'test.com', foo: 'bar', - url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly' }); + + it(`Delete: Called with composite options and returns 'delete'`, async () => { + sinon.stub(request, 'delete').resolves('delete'); + + const result = await couchRequest.delete(option); + + chai.expect(request.delete.args).to.deep.equal([[ + { + servername: 'test.com', + foo: 'bar', + url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('delete'); }); - it(`Delete: Called with url as first parameter and composite options and returns 'delete'`, async function () { - const requestDelete = sinon.stub(request, 'delete'); - requestDelete.returns(Promise.resolve('delete')); - const result = await couch_request.delete('a-test-url', option); - sinon.assert.calledWith(requestDelete, 'a-test-url', { - servername: 'test.com', foo: 'bar' }); + + it(`Delete: Called with url as first parameter and composite options and returns 'delete'`, async () => { + sinon.stub(request, 'delete').resolves('delete'); + + const result = await couchRequest.delete('a-test-url', option); + + chai.expect(request.delete.args).to.deep.equal([[ + 'a-test-url', + { + servername: 'test.com', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); + chai.expect(result).to.equal('delete'); }); - it(`Put: Called with composite options and returns 'put'`, async function () { - const requestPut = sinon.stub(request, 'put'); - requestPut.returns(Promise.resolve('put')); - const result = await couch_request.put(option); - sinon.assert.calledWith(requestPut, { - servername: 'test.com', foo: 'bar', - url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly' }); + + it(`Put: Called with composite options and returns 'put'`, async () => { + sinon.stub(request, 'put').resolves('put'); + + const result = await couchRequest.put(option); + + chai.expect(request.put.args).to.deep.equal([[ + { + servername: 'test.com', + foo: 'bar', + url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('put'); }); - it(`Put: Called with url as first parameter and composite options and returns 'put'`, async function () { - const requestPut = sinon.stub(request, 'put'); - requestPut.returns(Promise.resolve('put')); - const result = await couch_request.put('a-test-url', option); - sinon.assert.calledWith(requestPut, 'a-test-url', { - servername: 'test.com', foo: 'bar' }); + + it(`Put: Called with url as first parameter and composite options and returns 'put'`, async () => { + sinon.stub(request, 'put').resolves('put'); + + const result = await couchRequest.put('a-test-url', option); + + chai.expect(request.put.args).to.deep.equal([[ + 'a-test-url', + { + servername: 'test.com', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('put'); }); }); - const overrideOptions = [{ foo: 'bar', servername: 'bar' }]; + const overrideOptions = [ + { + foo: 'bar', + servername: 'bar', + }, + { + foo: 'bar', + servername: 'bar', + headers: { 'Accepts': 'application/json, text/plain, */*' }, + } + ]; overrideOptions.forEach(option => { - it(`Get: Called with options overridden and returns 'get'`, async function () { - const requestGet = sinon.stub(request, 'get'); - requestGet.returns(Promise.resolve('get')); - const result = await couch_request.get(option); - sinon.assert.calledWith(requestGet, { servername: 'bar', foo: 'bar' }); + it(`Get: Called with options overridden and returns 'get'`, async () => { + sinon.stub(request, 'get').resolves('get'); + + const result = await couchRequest.get(option); + + chai.expect(request.get.args).to.deep.equal([[ + { + servername: 'bar', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); + chai.expect(result).to.equal('get'); }); - it(`Get: Called with url in first param and options overridden and returns 'get'`, async function () { - const requestGet = sinon.stub(request, 'get'); - requestGet.returns(Promise.resolve('get')); - const result = await couch_request.get('a-test-url', option); - sinon.assert.calledWith(requestGet, 'a-test-url', { servername: 'bar', foo: 'bar' }); + + it(`Get: Called with url in first param and options overridden and returns 'get'`, async () => { + sinon.stub(request, 'get').resolves('get'); + + const result = await couchRequest.get('a-test-url', option); + + chai.expect(request.get.args).to.deep.equal([[ + 'a-test-url', + { + servername: 'bar', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('get'); }); - it(`Post: Called with options overridden and returns 'post'`, async function () { - const requestPost = sinon.stub(request, 'post'); - requestPost.returns(Promise.resolve('post')); - const result = await couch_request.post(option); - sinon.assert.calledWith(requestPost, { servername: 'bar', foo: 'bar' }); + + it(`Post: Called with options overridden and returns 'post'`, async () => { + sinon.stub(request, 'post').resolves('post'); + + const result = await couchRequest.post(option); + + chai.expect(request.post.args).to.deep.equal([[ + { + servername: 'bar', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('post'); }); - it(`Post: Called with url in first param and options overridden and returns 'post'`, async function () { - const requestPost = sinon.stub(request, 'post'); - requestPost.returns(Promise.resolve('post')); - const result = await couch_request.post('a-test-url', option); - sinon.assert.calledWith(requestPost, 'a-test-url', { servername: 'bar', foo: 'bar' }); + + it(`Post: Called with url in first param and options overridden and returns 'post'`, async () => { + sinon.stub(request, 'post').resolves('post'); + + const result = await couchRequest.post('a-test-url', option); + + chai.expect(request.post.args).to.deep.equal([[ + 'a-test-url', + { + servername: 'bar', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('post'); }); - it(`Head: Called with options overridden and returns 'head'`, async function () { - const requestHead = sinon.stub(request, 'head'); - requestHead.returns(Promise.resolve('head')); - const result = await couch_request.head(option); - sinon.assert.calledWith(requestHead, { servername: 'bar', foo: 'bar' }); + + it(`Head: Called with options overridden and returns 'head'`, async () => { + sinon.stub(request, 'head').resolves('head'); + + const result = await couchRequest.head(option); + + chai.expect(request.head.args).to.deep.equal([[ + { + servername: 'bar', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); + chai.expect(result).to.equal('head'); }); - it(`Head: Called with url in first param and options overridden and returns 'head'`, async function () { - const requestHead = sinon.stub(request, 'head'); - requestHead.returns(Promise.resolve('head')); - const result = await couch_request.head('a-test-url', option); - sinon.assert.calledWith(requestHead, 'a-test-url', { servername: 'bar', foo: 'bar' }); + + it(`Head: Called with url in first param and options overridden and returns 'head'`, async () => { + sinon.stub(request, 'head').resolves('head'); + + const result = await couchRequest.head('a-test-url', option); + + chai.expect(request.head.args).to.deep.equal([[ + 'a-test-url', + { + servername: 'bar', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('head'); }); - it(`Delete: Called with options overridden and returns 'delete'`, async function () { - const requestDelete = sinon.stub(request, 'delete'); - requestDelete.returns(Promise.resolve('delete')); - const result = await couch_request.delete(option); - sinon.assert.calledWith(requestDelete, { servername: 'bar', foo: 'bar' }); + + it(`Delete: Called with options overridden and returns 'delete'`, async () => { + sinon.stub(request, 'delete').resolves('delete'); + + const result = await couchRequest.delete(option); + + chai.expect(request.delete.args).to.deep.equal([[ + { + servername: 'bar', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); + chai.expect(result).to.equal('delete'); }); - it(`Delete: Called with url in first param and options overridden and returns 'delete'`, async function () { - const requestDelete = sinon.stub(request, 'delete'); - requestDelete.returns(Promise.resolve('delete')); - const result = await couch_request.delete('a-test-url', option); - sinon.assert.calledWith(requestDelete, 'a-test-url', { servername: 'bar', foo: 'bar' }); + + it(`Delete: Called with url in first param and options overridden and returns 'delete'`, async () => { + sinon.stub(request, 'delete').resolves('delete'); + + const result = await couchRequest.delete('a-test-url', option); + + chai.expect(request.delete.args).to.deep.equal([[ + 'a-test-url', + { + servername: 'bar', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('delete'); }); - it(`Put: Called with options overridden and returns 'put'`, async function () { - const requestPut = sinon.stub(request, 'put'); - requestPut.returns(Promise.resolve('put')); - const result = await couch_request.put(option); - sinon.assert.calledWith(requestPut, { servername: 'bar', foo: 'bar' }); + + it(`Put: Called with options overridden and returns 'put'`, async () => { + sinon.stub(request, 'put').resolves('put'); + + const result = await couchRequest.put(option); + + chai.expect(request.put.args).to.deep.equal([[ + { + servername: 'bar', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('put'); }); - it(`Put: Called with url in first param and options overridden and returns 'put'`, async function () { - const requestPut = sinon.stub(request, 'put'); - requestPut.returns(Promise.resolve('put')); - const result = await couch_request.put('a-test-url', option); - sinon.assert.calledWith(requestPut, 'a-test-url', { servername: 'bar', foo: 'bar' }); + + it(`Put: Called with url in first param and options overridden and returns 'put'`, async () => { + sinon.stub(request, 'put').resolves('put'); + + const result = await couchRequest.put('a-test-url', option); + + chai.expect(request.put.args).to.deep.equal([[ + 'a-test-url', + { + servername: 'bar', + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('put'); }); }); @@ -312,7 +549,7 @@ describe('Couch request with servername added receives correct options and retur describe('Couch request with default servername omitted receives correct options and returns stub value', () => { - let couch_request; + let couchRequest; before(() => { sinon.stub(process, 'env').value({ @@ -320,109 +557,290 @@ describe('Couch request with default servername omitted receives correct options COUCH_URL: 'http://admin:password@test.com:5984/medic', ADD_SERVERNAME_TO_HTTP_AGENT: 'false' }); - couch_request = rewire('../src/couch-request'); + couchRequest = rewire('../src/couch-request'); }); afterEach(() => { sinon.restore(); }); - const options = [{ - foo: 'bar', url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', - uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', - method: 'GET' // Should not be passed to options - }]; + const options = [ + { + foo: 'bar', + url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + method: 'GET' // Should not be passed to options + }, + { + foo: 'bar', + url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + method: 'GET', // Should not be passed to options + headers: { 'Authorization': 'Basic 123' }, + }, + ]; options.forEach(option => { - it(`Get: Called with composite options and returns 'get'`, async function () { - const requestGet = sinon.stub(request, 'get'); - requestGet.returns(Promise.resolve('get')); - const result = await couch_request.get(option); - sinon.assert.calledWith(requestGet, { - foo: 'bar', - url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly' }); + it(`Get: Called with composite options and returns 'get'`, async () => { + sinon.stub(request, 'get').resolves('get'); + + const result = await couchRequest.get(option); + + chai.expect(request.get.args).to.deep.equal([[ + { + foo: 'bar', + url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + ...(option.headers && { headers: option.headers }), + } + ]]); + chai.expect(result).to.equal('get'); }); - it(`Post: Called with composite options and returns 'post'`, async function () { - const requestPost = sinon.stub(request, 'post'); - requestPost.returns(Promise.resolve('post')); - const result = await couch_request.post(option); - sinon.assert.calledWith(requestPost, { - foo: 'bar', - url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly' }); + + it(`Post: Called with composite options and returns 'post'`, async () => { + sinon.stub(request, 'post').resolves('post'); + + const result = await couchRequest.post(option); + + chai.expect(request.post.args).to.deep.equal([[ + { + foo: 'bar', + url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('post'); }); - it(`Head: Called with url as first parameter and composite options and returns 'head'`, async function () { - const requestHead = sinon.stub(request, 'head'); - requestHead.returns(Promise.resolve('head')); - const result = await couch_request.head('a-test-url', option); - sinon.assert.calledWith(requestHead, 'a-test-url', { - foo: 'bar' }); + + it(`Head: Called with url as first parameter and composite options and returns 'head'`, async () => { + sinon.stub(request, 'head').resolves('head'); + + const result = await couchRequest.head('a-test-url', option); + + chai.expect(request.head.args).to.deep.equal([[ + 'a-test-url', + { + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); + chai.expect(result).to.equal('head'); }); - it(`Delete: Called with url as first parameter and composite options and returns 'delete'`, async function () { - const requestDelete = sinon.stub(request, 'delete'); - requestDelete.returns(Promise.resolve('delete')); - const result = await couch_request.delete('a-test-url', option); - sinon.assert.calledWith(requestDelete, 'a-test-url', { - foo: 'bar' }); + + it(`Delete: Called with url as first parameter and composite options and returns 'delete'`, async () => { + sinon.stub(request, 'delete').resolves('delete'); + + const result = await couchRequest.delete('a-test-url', option); + + chai.expect(request.delete.args).to.deep.equal([[ + 'a-test-url', + { + foo: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('delete'); }); - it(`Put: Called with composite options and returns 'put'`, async function () { - const requestPut = sinon.stub(request, 'put'); - requestPut.returns(Promise.resolve('put')); - const result = await couch_request.put(option); - sinon.assert.calledWith(requestPut, { - foo: 'bar', - url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly' }); + + it(`Put: Called with composite options and returns 'put'`, async () => { + sinon.stub(request, 'put').resolves('put'); + + const result = await couchRequest.put(option); + + chai.expect(request.put.args).to.deep.equal([[ + { + foo: 'bar', + url: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + uri: 'shouldBeOverriddenWhenFirstParamIsAStringOnly', + ...(option.headers && { headers: option.headers }), + } + ]]); + chai.expect(result).to.equal('put'); }); }); - const overrideOptions = [{ foo: 'bar', servername: 'bar' }]; + const overrideOptions = [ + { + foo: 'bar', + servername: 'bar', + }, + { + foo: 'bar', + servername: 'bar', + headers: { 'Authorization': 'Basic 123' }, + }, + ]; overrideOptions.forEach(option => { it(`Get: Called with url in first param and options overridden, - servername allowed as optional override and returns 'get'`, async function () { - const requestGet = sinon.stub(request, 'get'); - requestGet.returns(Promise.resolve('get')); - const result = await couch_request.get('a-test-url', option); - sinon.assert.calledWith(requestGet, 'a-test-url', { servername: 'bar', foo: 'bar' }); + servername allowed as optional override and returns 'get'`, async () => { + sinon.stub(request, 'get').resolves('get'); + + const result = await couchRequest.get('a-test-url', option); + + chai.expect(request.get.args).to.deep.equal([[ + 'a-test-url', + { + foo: 'bar', + servername: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('get'); }); + it(`Post: Called with options overridden, - servername allowed as optional override and returns 'post'`, async function () { - const requestPost = sinon.stub(request, 'post'); - requestPost.returns(Promise.resolve('post')); - const result = await couch_request.post(option); - sinon.assert.calledWith(requestPost, { servername: 'bar', foo: 'bar' }); + servername allowed as optional override and returns 'post'`, async () => { + sinon.stub(request, 'post').resolves('post'); + + const result = await couchRequest.post(option); + + chai.expect(request.post.args).to.deep.equal([[ + { + foo: 'bar', + servername: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('post'); }); + it(`Head: Called with url in first param and options overridden, - servername allowed as optional override and returns 'head'`, async function () { - const requestHead = sinon.stub(request, 'head'); - requestHead.returns(Promise.resolve('head')); - const result = await couch_request.head('a-test-url', option); - sinon.assert.calledWith(requestHead, 'a-test-url', { servername: 'bar', foo: 'bar' }); + servername allowed as optional override and returns 'head'`, async () => { + sinon.stub(request, 'head').resolves('head'); + + const result = await couchRequest.head('a-test-url', option); + + chai.expect(request.head.args).to.deep.equal([[ + 'a-test-url', + { + foo: 'bar', + servername: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('head'); }); + it(`Delete: Called with options overridden, - servername allowed as optional override and returns 'delete'`, async function () { - const requestDelete = sinon.stub(request, 'delete'); - requestDelete.returns(Promise.resolve('delete')); - const result = await couch_request.delete(option); - sinon.assert.calledWith(requestDelete, { servername: 'bar', foo: 'bar' }); + servername allowed as optional override and returns 'delete'`, async () => { + sinon.stub(request, 'delete').resolves('delete'); + + const result = await couchRequest.delete(option); + + chai.expect(request.delete.args).to.deep.equal([[ + { + foo: 'bar', + servername: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('delete'); }); + it(`Put: Called with url in first param and options overridden, - servername allowed as optional override and returns 'put'`, async function () { - const requestPut = sinon.stub(request, 'put'); - requestPut.returns(Promise.resolve('put')); - const result = await couch_request.put('a-test-url', option); - sinon.assert.calledWith(requestPut, 'a-test-url', { servername: 'bar', foo: 'bar' }); + servername allowed as optional override and returns 'put'`, async () => { + sinon.stub(request, 'put').resolves('put'); + + const result = await couchRequest.put('a-test-url', option); + chai.expect(request.put.args).to.deep.equal([[ + 'a-test-url', + { + foo: 'bar', + servername: 'bar', + ...(option.headers && { headers: option.headers }), + } + ]]); chai.expect(result).to.equal('put'); }); }); }); + +describe('request id header', () => { + let couchRequest; + + before(() => { + sinon.stub(process, 'env').value({ + ...process.env, + COUCH_URL: 'http://admin:password@test.com:5984/medic', + ADD_SERVERNAME_TO_HTTP_AGENT: 'true' + }); + couchRequest = rewire('../src/couch-request'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should not add request id header when async storage is not set', async () => { + sinon.stub(request, 'get').resolves('got'); + + const response = await couchRequest.get('foobar'); + + chai.expect(response).to.equal('got'); + chai.expect(request.get.args).to.deep.equal([[ + 'foobar', + { + servername: 'test.com', + } + ]]); + }); + + + it('should not add request id header when client request is not set', async () => { + sinon.stub(request, 'get').resolves('got'); + const asyncLocalStorage = { getRequestId: sinon.stub().returns(false) }; + couchRequest.initialize(asyncLocalStorage, 'header-name'); + + const response = await couchRequest.get('foobar'); + + chai.expect(response).to.equal('got'); + chai.expect(request.get.args).to.deep.equal([[ + 'foobar', + { + servername: 'test.com', + } + ]]); + }); + + it('should set request id header when set', async () => { + sinon.stub(request, 'get').resolves('got'); + const asyncLocalStorage = { getRequestId: sinon.stub().returns('req_uuid') }; + couchRequest.initialize(asyncLocalStorage, 'header-name'); + + const response = await couchRequest.get('foobar'); + chai.expect(response).to.equal('got'); + chai.expect(request.get.args).to.deep.equal([[ + 'foobar', + { + servername: 'test.com', + headers: { 'header-name': 'req_uuid' } + } + ]]); + }); + + it('should add request id header when headers are already set', async () => { + sinon.stub(request, 'get').resolves('got'); + const asyncLocalStorage = { getRequestId: sinon.stub().returns('req_uuid') }; + couchRequest.initialize(asyncLocalStorage, 'header-name'); + + const response = await couchRequest.get('foobar', { headers: { 'Authorization': 'Basic 123' } }); + chai.expect(response).to.equal('got'); + chai.expect(request.get.args).to.deep.equal([[ + 'foobar', + { + servername: 'test.com', + headers: { + 'header-name': 'req_uuid', + 'Authorization': 'Basic 123', + } + } + ]]); + }); +}); diff --git a/tests/e2e/default/telemetry/telemetry.wdio-spec.js b/tests/e2e/default/telemetry/telemetry.wdio-spec.js index 2ee5d183239..b9aea8f5366 100644 --- a/tests/e2e/default/telemetry/telemetry.wdio-spec.js +++ b/tests/e2e/default/telemetry/telemetry.wdio-spec.js @@ -120,7 +120,7 @@ describe('Telemetry', () => { expect(clientDdoc.build_info.version).to.include(version); }); - describe('search matches telemetry', () => { + describe.skip('search matches telemetry', () => { afterEach(async () => { // eslint-disable-next-line no-undef await browser.execute((dbName) => window.PouchDB(dbName).destroy(), todayDBName); diff --git a/tests/integration/api/server.spec.js b/tests/integration/api/server.spec.js index 3625695aa26..16c529221c6 100644 --- a/tests/integration/api/server.spec.js +++ b/tests/integration/api/server.spec.js @@ -2,6 +2,9 @@ const utils = require('@utils'); const request = require('request'); const constants = require('@constants'); const _ = require('lodash'); +const placeFactory = require('@factories/cht/contacts/place'); +const personFactory = require('@factories/cht/contacts/person'); +const userFactory = require('@factories/cht/users/users'); describe('server', () => { describe('JSON-only endpoints', () => { @@ -230,4 +233,201 @@ describe('server', () => { await utils.listenForApi(); }); }); + + describe('Request ID propagated to audit layer', () => { + const ID_REGEX = /[,|\s]([0-9a-f]{12})[,|\s]/; + + const getReqId = (logLine) => { + if (ID_REGEX.test(logLine)) { + const match = logLine.match(ID_REGEX); + return match?.[1]; + } + }; + + describe('for online users', () => { + it('should propagate ID via proxy', async () => { + const collectApiLogs = await utils.collectApiLogs(/\/_all_docs\?limit=1/); + const collectHaproxyLogs = await utils.collectHaproxyLogs(/\/_all_docs\?limit=1/); + + const result = await utils.request('/medic/_all_docs?limit=1'); + await utils.delayPromise(500); // wait for everything to get logged + + const apiLogs = (await collectApiLogs()).filter(log => log.length); + const haproxyLogs = (await collectHaproxyLogs()).filter(log => log.length); + + expect(result.rows.length).to.equal(1); + expect(apiLogs.length).to.equal(2); + const apiReqId = getReqId(apiLogs[0]); + const haproxyReqId = getReqId(haproxyLogs[0]); + + expect(apiReqId.length).to.equal(12); + expect(haproxyReqId).to.equal(apiReqId); + }); + + it('should propagate ID via PouchDb', async () => { + const collectApiLogs = await utils.collectApiLogs(/hydrate/); + const collectHaproxyLogs = await utils.collectHaproxyLogs(/.*/); + + const result = await utils.request({ path: '/api/v1/hydrate', qs: { doc_ids: [constants.USER_CONTACT_ID] }}); + await utils.delayPromise(500); // wait for everything to get logged + + const apiLogs = (await collectApiLogs()).filter(log => log.length); + const haproxyLogs = (await collectHaproxyLogs()).filter(log => log.length); + + expect(result.length).to.equal(1); + const reqID = getReqId(apiLogs[0]); + + const haproxyRequests = haproxyLogs.filter(entry => getReqId(entry) === reqID); + expect(haproxyRequests.length).to.equal(2); + expect(haproxyRequests[0]).to.include('_session'); + expect(haproxyRequests[1]).to.include('_design/medic-client/_view/docs_by_id_lineage'); + }); + + it('should propagate ID via couch-request', async () => { + const collectApiLogs = await utils.collectApiLogs(/couch-config-attachments/); + const collectHaproxyLogs = await utils.collectHaproxyLogs(/.*/); + + await utils.request({ path: '/api/couch-config-attachments' }); + await utils.delayPromise(500); // wait for everything to get logged + + const apiLogs = (await collectApiLogs()).filter(log => log.length); + const haproxyLogs = (await collectHaproxyLogs()).filter(log => log.length); + + const reqID = getReqId(apiLogs[0]); + + const haproxyRequests = haproxyLogs.filter(entry => getReqId(entry) === reqID); + expect(haproxyRequests.length).to.equal(3); + expect(haproxyRequests[0]).to.include('_session'); + expect(haproxyRequests[1]).to.include('_session'); + expect(haproxyRequests[2]).to.include('/_node/_local/_config/attachments'); + }); + + it('should use a different id for different requests', async () => { + const collectApiLogs = await utils.collectApiLogs(/.*/); + const collectHaproxyLogs = await utils.collectHaproxyLogs(/.*/); + + await utils.request({ path: '/api/couch-config-attachments' }); + await utils.request({ path: '/api/v1/hydrate', qs: { doc_ids: [constants.USER_CONTACT_ID] }}); + + const apiLogs = (await collectApiLogs()).filter(log => log.length); + const haproxyLogs = (await collectHaproxyLogs()).filter(log => log.length); + + const configReqId = apiLogs + .filter(log => log.includes('couch-config-attachments')) + .map((log) => getReqId(log))[0]; + const hydrateReqId = apiLogs + .filter(log => log.includes('hydrate')) + .map((log) => getReqId(log))[0]; + + const haproxyConfigReqs = haproxyLogs.filter(entry => getReqId(entry) === configReqId); + expect(haproxyConfigReqs.length).to.equal(3); + + const haproxyHydrateReqs = haproxyLogs.filter(entry => getReqId(entry) === hydrateReqId); + expect(haproxyHydrateReqs.length).to.equal(2); + + expect(hydrateReqId).not.to.equal(configReqId); + }); + }); + + describe('for offline users', () => { + let reqOptions; + let offlineUser; + before(async () => { + const placeMap = utils.deepFreeze(placeFactory.generateHierarchy()); + const contact = utils.deepFreeze(personFactory.build({ name: 'contact', role: 'chw' })); + const place = utils.deepFreeze({ ...placeMap.get('clinic'), contact: { _id: contact._id } }); + offlineUser = utils.deepFreeze(userFactory.build({ + username: 'offline-user-id', + place: place._id, + contact: { + _id: 'fixture:user:offline', + name: 'Offline User', + }, + roles: ['chw'] + })); + + await utils.saveDocs([contact, ...placeMap.values()]); + await utils.createUsers([offlineUser]); + + reqOptions = { + auth: { username: offlineUser.username, password: offlineUser.password }, + }; + }); + + it('should propagate ID via PouchDb requests', async () => { + const collectApiLogs = await utils.collectApiLogs(/replication/); + const collectHaproxyLogs = await utils.collectHaproxyLogs(/.*/); + + await utils.request({ path: '/api/v1/initial-replication/get-ids', ...reqOptions }); + await utils.delayPromise(500); // wait for everything to get logged + + const apiLogs = (await collectApiLogs()).filter(log => log.length); + const haproxyLogs = (await collectHaproxyLogs()).filter(log => log.length); + + const reqID = getReqId(apiLogs[0]); + + const haproxyRequests = haproxyLogs.filter(entry => getReqId(entry) === reqID); + expect(haproxyRequests.length).to.equal(12); + expect(haproxyRequests[0]).to.include('_session'); + expect(haproxyRequests[5]).to.include('/medic-test/_design/medic/_view/contacts_by_depth'); + expect(haproxyRequests[6]).to.include('/medic-test/_design/medic/_view/docs_by_replication_key'); + expect(haproxyRequests[7]).to.include('/medic-test-purged-cache/purged-docs-'); + expect(haproxyRequests[8]).to.include('/medic-test-purged-role-'); + expect(haproxyRequests[9]).to.include('/medic-test-logs/replication-count-'); + expect(haproxyRequests[10]).to.include('/medic-test-logs/replication-count-'); + expect(haproxyRequests[11]).to.include('/medic-test/_all_docs'); + }); + + it('should propagate ID via couch requests', async () => { + const collectApiLogs = await utils.collectApiLogs(/meta/); + const collectHaproxyLogs = await utils.collectHaproxyLogs(/.*/); + + await utils.request({ + path: `/medic-user-${offlineUser.username}-meta/`, + method: 'PUT', + ...reqOptions, + }); + await utils.delayPromise(500); // wait for everything to get logged + + const apiLogs = (await collectApiLogs()).filter(log => log.length); + const haproxyLogs = (await collectHaproxyLogs()).filter(log => log.length); + + const reqID = getReqId(apiLogs[0]); + + const haproxyRequests = haproxyLogs.filter(entry => getReqId(entry) === reqID); + expect(haproxyRequests.length).to.equal(7); + expect(haproxyRequests[0]).to.include('_session'); + expect(haproxyRequests[1]).to.include('_session'); + expect(haproxyRequests[2]).to.include(`medic-test-user-${offlineUser.username}-meta`); + expect(haproxyRequests[3]).to.include(`medic-test-user-${offlineUser.username}-meta`); + expect(haproxyRequests[4]).to.include(`medic-test-user-${offlineUser.username}-meta`); + expect(haproxyRequests[5]).to.include(`medic-test-user-${offlineUser.username}-meta/_design/medic-user`); + expect(haproxyRequests[6]).to.include(`medic-test-user-${offlineUser.username}-meta/_security`); + }); + + it('should propagate ID via proxy', async () => { + const collectApiLogs = await utils.collectApiLogs(/meta/); + const collectHaproxyLogs = await utils.collectHaproxyLogs(/.*/); + + await utils.request({ + path: `/medic-user-${offlineUser.username}-meta/the_doc`, + method: 'PUT', + body: { _id: 'the_doc', value: true }, + ...reqOptions, + }); + await utils.delayPromise(500); // wait for everything to get logged + + const apiLogs = (await collectApiLogs()).filter(log => log.length); + const haproxyLogs = (await collectHaproxyLogs()).filter(log => log.length); + + const reqID = getReqId(apiLogs[0]); + + const haproxyRequests = haproxyLogs.filter(entry => getReqId(entry) === reqID); + expect(haproxyRequests.length).to.equal(2); + expect(haproxyRequests[0]).to.include('_session'); + expect(haproxyRequests[1]).to.include(`/medic-test-user-${offlineUser.username}-meta/the_doc`); + }); + }); + + }); });