From c10ac9d7fe7d4ac619a101096ca17121ec4844c3 Mon Sep 17 00:00:00 2001 From: Diana Barsan Date: Tue, 14 Jan 2025 18:15:57 +0200 Subject: [PATCH] switches to fetch in couch-request.js Signed-off-by: Diana Barsan --- api/src/auth.js | 24 +-- api/src/controllers/login.js | 15 +- api/src/db-batch.js | 11 +- api/src/migrations/fix-user-db-security.js | 2 +- .../migrations/restrict-access-to-audit-db.js | 4 +- .../restrict-access-to-sentinel-db.js | 4 +- .../migrations/restrict-access-to-vault-db.js | 4 +- api/src/services/africas-talking.js | 30 +-- api/src/services/rapidpro.js | 4 +- api/src/services/user-db.js | 4 +- .../couch-request/src/couch-request.js | 174 ++++++++++-------- .../api/controllers/replication.spec.js | 1 + tests/integration/api/server.spec.js | 3 + tests/utils/index.js | 9 + 14 files changed, 162 insertions(+), 127 deletions(-) diff --git a/api/src/auth.js b/api/src/auth.js index 508ffc81906..b52807699e6 100644 --- a/api/src/auth.js +++ b/api/src/auth.js @@ -51,7 +51,7 @@ module.exports = { getUserCtx: req => { return get('/_session', req.headers) .catch(err => { - if (err.statusCode === 401) { + if (err.status === 401) { throw { code: 401, message: 'Not logged in', err: err }; } throw err; @@ -83,12 +83,12 @@ module.exports = { * @return {Object} {username: username, password: password} */ basicAuthCredentials: req => { - const authHeader = req && req.headers && req.headers.authorization; + const authHeader = req?.headers?.authorization; if (!authHeader || !authHeader.startsWith('Basic ')) { return false; } try { - const [username, password] = Buffer.from(authHeader.split(' ')[1], 'base64').toString().split(':'); + const [username, password] = atob(authHeader.split(' ')[1]).split(':'); return { username, password }; } catch (err) { throw Error('Corrupted Auth header'); @@ -101,16 +101,16 @@ module.exports = { * @param {Object} Credentials object as created by basicAuthCredentials */ validateBasicAuth: ({ username, password }) => { - const authUrl = new URL(environment.serverUrlNoAuth); - authUrl.username = username; - authUrl.password = password; - return request.head({ - uri: authUrl.toString(), - resolveWithFullResponse: true - }) + return request + .get({ + uri: environment.serverUrlNoAuth, + auth: { username, password }, + simple: false, + json: false, + }) .then(res => { - if (res.statusCode !== 200) { - return Promise.reject(new Error(`Expected 200 got ${res.statusCode}`)); + if (!res.ok) { + return Promise.reject(new Error(`Expected 200 got ${res.status}`)); } return username; }); diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index f522bcce7bf..5c21f6dbfe0 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -160,22 +160,19 @@ const render = (page, req, extras = {}) => { }; const getSessionCookie = res => { - return _.find( - res.headers['set-cookie'], - cookie => cookie.indexOf('AuthSession') === 0 - ); + return res.headers.getSetCookie().find(cookie => cookie.indexOf('AuthSession') === 0); }; const createSession = req => { const user = req.body.user; const password = req.body.password; + return request.post({ url: new URL('/_session', environment.serverUrlNoAuth).toString(), json: true, - resolveWithFullResponse: true, simple: false, // doesn't throw an error on non-200 responses body: { name: user, password: password }, - auth: { user: user, pass: password }, + auth: { username: user, password: password }, }); }; @@ -242,7 +239,7 @@ const getUserCtxRetry = async (options, retry = 10) => { const createSessionRetry = (req, retry=10) => { return createSession(req).then(sessionRes => { - if (sessionRes.statusCode === 200) { + if (sessionRes.status === 200) { return sessionRes; } @@ -303,8 +300,8 @@ const renderLogin = (req) => { const login = async (req, res) => { try { const sessionRes = await createSession(req); - if (sessionRes.statusCode !== 200) { - res.status(sessionRes.statusCode).json({ error: 'Not logged in' }); + if (sessionRes.status !== 200) { + res.status(sessionRes.status).json({ error: 'Not logged in' }); } else { const redirectUrl = await setCookies(req, res, sessionRes); res.status(302).send(redirectUrl); diff --git a/api/src/db-batch.js b/api/src/db-batch.js index 9108946b891..f03114e8bd8 100644 --- a/api/src/db-batch.js +++ b/api/src/db-batch.js @@ -18,11 +18,12 @@ const runBatch = (ddoc, view, viewParams, iteratee) => { }); // using request here instead of PouchDB because // PouchDB doesn't support startkey_docid: #5319 - return request.get({ - url: fullUrl, - json: true, - auth: { user: environment.username, pass: environment.password }, - }) + return request + .get({ + url: fullUrl, + json: true, + auth: { username: environment.username, password: environment.password }, + }) .then(result => { logger.info(` Processing doc ${result.offset}`); let nextPage; diff --git a/api/src/migrations/fix-user-db-security.js b/api/src/migrations/fix-user-db-security.js index 233c5b885b0..90bd367a9bd 100644 --- a/api/src/migrations/fix-user-db-security.js +++ b/api/src/migrations/fix-user-db-security.js @@ -17,7 +17,7 @@ module.exports = { return p.then(() => { return userDb.setSecurity(userDb.getDbName(username), username) .catch(err => { - if (err.statusCode !== 404) { + if (err.status !== 404) { throw err; } // db not found is ok diff --git a/api/src/migrations/restrict-access-to-audit-db.js b/api/src/migrations/restrict-access-to-audit-db.js index df4a62e526e..ed2bb4cba9f 100644 --- a/api/src/migrations/restrict-access-to-audit-db.js +++ b/api/src/migrations/restrict-access-to-audit-db.js @@ -15,8 +15,8 @@ const addMemberToDb = () => { pathname: `${environment.db}-audit/_security`, }), auth: { - user: environment.username, - pass: environment.password + username: environment.username, + password: environment.password }, json: true, body: securityObject diff --git a/api/src/migrations/restrict-access-to-sentinel-db.js b/api/src/migrations/restrict-access-to-sentinel-db.js index c20cf96249e..e9a2e257a4a 100644 --- a/api/src/migrations/restrict-access-to-sentinel-db.js +++ b/api/src/migrations/restrict-access-to-sentinel-db.js @@ -16,8 +16,8 @@ const addSecurityToDb = () => { pathname: `${environment.db}-sentinel/_security`, }), auth: { - user: environment.username, - pass: environment.password + username: environment.username, + password: environment.password }, json: true, body: securityObject diff --git a/api/src/migrations/restrict-access-to-vault-db.js b/api/src/migrations/restrict-access-to-vault-db.js index c4f85109e03..e15914bd03a 100644 --- a/api/src/migrations/restrict-access-to-vault-db.js +++ b/api/src/migrations/restrict-access-to-vault-db.js @@ -16,8 +16,8 @@ const addSecurityToDb = () => { pathname: `${environment.db}-vault/_security`, }), auth: { - user: environment.username, - pass: environment.password + username: environment.username, + password: environment.password }, json: true, body: securityObject diff --git a/api/src/services/africas-talking.js b/api/src/services/africas-talking.js index 8c946bfce2a..79647fddce9 100644 --- a/api/src/services/africas-talking.js +++ b/api/src/services/africas-talking.js @@ -50,7 +50,7 @@ const getRecipient = res => { res.SMSMessageData.Recipients[0]; }; -const getStatus = recipient => recipient && STATUS_MAP[recipient.statusCode]; +const getStatus = recipient => recipient && STATUS_MAP[recipient.status]; const generateStateChange = (message, res) => { const recipient = getRecipient(res); @@ -85,20 +85,20 @@ const parseResponseBody = body => { const sendMessage = (credentials, message) => { const url = getUrl(credentials.username === 'sandbox'); logger.debug(`Sending message to "${url}"`); - return request.post({ - url: url, - simple: false, - form: { - username: credentials.username, - from: credentials.from, - to: message.to, - message: message.content - }, - headers: { - apikey: credentials.apiKey, - Accept: 'application/json' - } - }) + return request + .post({ + url: url, + form: { + username: credentials.username, + from: credentials.from, + to: message.to, + message: message.content + }, + headers: { + apikey: credentials.apiKey, + Accept: 'application/json' + } + }) .then(body => { const result = parseResponseBody(body); if (!result) { diff --git a/api/src/services/rapidpro.js b/api/src/services/rapidpro.js index e1703f5979b..ccfbc2a0905 100644 --- a/api/src/services/rapidpro.js +++ b/api/src/services/rapidpro.js @@ -108,7 +108,7 @@ const sendMessage = (credentials, message) => { }) .catch(err => { logger.error(`Error thrown when trying to send message: %o`, err); - if (err?.statusCode === 400) { + if (err?.status === 400) { // source https://rapidpro.io/api/v2/ // 400: The request failed due to invalid parameters. // Do not retry with the same values, and the body of the response will contain details. @@ -168,7 +168,7 @@ const getRemoteStates = (credentials, messages) => { stateUpdates.push(stateUpdate); }) .catch(err => { - if (err && err.statusCode === 429) { + if (err && err.status === 429) { // rate limited, throw error to halt recursive polling throttled = true; } diff --git a/api/src/services/user-db.js b/api/src/services/user-db.js index a8703748a9e..5a2795772f1 100644 --- a/api/src/services/user-db.js +++ b/api/src/services/user-db.js @@ -80,8 +80,8 @@ module.exports = { pathname: `${dbName}/_security`, }), auth: { - user: environment.username, - pass: environment.password + username: environment.username, + password: environment.password }, json: true, body: { diff --git a/shared-libs/couch-request/src/couch-request.js b/shared-libs/couch-request/src/couch-request.js index c33b8eac7f2..5aebd4e5691 100644 --- a/shared-libs/couch-request/src/couch-request.js +++ b/shared-libs/couch-request/src/couch-request.js @@ -1,5 +1,3 @@ -const request = require('request-promise-native'); -const isPlainObject = require('lodash/isPlainObject'); const environment = require('@medic/environment'); const servername = environment.host; let asyncLocalStorage; @@ -10,31 +8,6 @@ const isString = value => typeof value === 'string' || value instanceof String; const isTrue = value => isString(value) ? value.toLowerCase() === 'true' : value === true; const addServername = isTrue(process.env.ADD_SERVERNAME_TO_HTTP_AGENT); -const methods = { - GET: 'GET', - POST: 'POST', - DELETE: 'DELETE', - PUT: 'PUT', - HEAD: 'HEAD' -}; - - -const mergeOptions = (target, source, exclusions = []) => { - for (const [key, value] of Object.entries(source)) { - if (Array.isArray(exclusions) && exclusions.includes(key)) { - 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; -}; // When proxying to HTTPS from HTTP (for example where an ingress does TLS termination in an SNI environment), // not including a 'servername' for a request to the HTTPS server (eg, def.org) produces the @@ -47,69 +20,120 @@ const mergeOptions = (target, source, exclusions = []) => { // The addition of 'servername' resolves this error. See docs for 'tls.connect(options[, callback])' // (https://nodejs.org/api/tls.html): "Server name for the SNI (Server Name Indication) TLS extension It is the // name of the host being connected to, and must be a host name, and not an IP address.". -// - -const validate = (firstIsString, method, first, second = {}) => { +// - if (Object.hasOwn(methods, method) === false) { - throw new Error(`Unsupported method (${method}) passed to call.`); +const setRequestUri = (options) => { + let uri = (options.uri || options.url); + if (options.baseUrl) { + uri = `${options.baseUrl}${uri}`; } - if (isPlainObject(second) === false) { - throw new Error(`"options" must be a plain object'`); + if (options.qs) { + Object.keys(options.qs).forEach((key) => { + if (Array.isArray(options.qs[key])) { + options.qs[key] = JSON.stringify(options.qs[key]); + } + }); + uri = `${uri}?${new URLSearchParams(options.qs).toString()}`; } - if (firstIsString === false && isPlainObject(first) === false) { - throw new Error(`"options" must be a plain object'`); - } + options.uri = uri; }; +const setRequestAuth = (options) => { + let auth; + + if (options.auth) { + auth = options.auth; + } else { + const url = new URL(options.uri); + if (url.username) { + auth = { username: url.username, password: url.password }; + url.username = ''; + url.password = ''; + options.uri = url.toString(); + } + } -const req = (method, first, second = {}) => { + if (!auth) { + return; + } - const firstIsString = isString(first); + const basicAuth = btoa(`${auth.username}:${auth.password}`); + options.headers.Authorization = `Basic ${basicAuth}`; +}; - try { - validate(firstIsString, method, first, second); - } catch (e) { - return Promise.reject(e); +const setRequestContentType = (options) => { + let sendJson = true; + if (options.json === false || + (options.headers['Content-Type'] && options.headers['Content-Type'] !== 'application/json') + ) { + sendJson = false; } - const chosenOptions = firstIsString ? second : first; + if (sendJson) { + options.headers.Accept = 'application/json'; + options.headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify(options.body); + } - const exclusions = firstIsString ? ['url', 'uri', 'method'] : ['method']; - const target = addServername ? { servername } : { }; - - const mergedOptions = mergeOptions(target, chosenOptions, exclusions); + if (!sendJson && options.form) { + const formData = new FormData(); + Object.keys(options.form).forEach(key => formData.append(key, options.form[key])); + options.headers['Content-Type'] = 'multipart/form-data'; + options.body = formData; + } - return firstIsString ? getRequestType(method)(first, mergedOptions) : getRequestType(method)(mergedOptions); + return sendJson; }; -const getRequestType = (method) => { - // This is intended to simplify testing as sinon does not stub an - // exported function (eg, 'export = requestPromise') but only methods. - // From reading, proxyquire would need to be used to solve: https://www.npmjs.com/package/proxyquire - // See: https://github.com/sinonjs/sinon/issues/562#issuecomment-79227487 - switch (method) { - case methods.GET: { - return request.get; - } - case methods.POST: { - return request.post; - } - case methods.PUT: { - return request.put; - } - case methods.DELETE: { - return request.delete; +const getRequestOptions = (options, servername) => { + options.headers = options.headers || {}; + + const requestId = asyncLocalStorage?.getRequestId(); + if (requestId) { + options.headers[requestIdHeader] = requestId; } - case methods.HEAD: { - return request.head; + + setRequestUri(options); + setRequestAuth(options); + const sendJson = setRequestContentType(options); + if (addServername) { + options.servername = servername; } - default: { - return Promise.reject(Error(`Unsupported method (${method}) passed to call.`)); + + return { options, sendJson }; +}; + +const getResponseBody = async (response, sendJson) => { + const receiveJson = (!response.headers.get('content-type') && sendJson) || + response.headers.get('content-type')?.startsWith('application/json'); + return receiveJson ? await response.json() : await response.text(); +}; + +const request = async (options = {}) => { + const { options: requestInit, sendJson } = getRequestOptions(options, servername); + + const response = await fetch(requestInit.uri, requestInit); + const responseObj = { + ...response, + body: await getResponseBody(response, sendJson), + status: response.status, + ok: response.ok, + headers: response.headers + }; + + if (options.simple === false) { + return responseObj; } + + if (response.ok || (response.status > 300 && response.status < 399)) { + return responseObj.body; } + + const err = new Error(response.error || `${response.status} - ${JSON.stringify(responseObj.body)}`); + Object.assign(err, responseObj); + throw err; }; module.exports = { @@ -118,9 +142,9 @@ module.exports = { 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), - delete: (first, second = {}) => req(methods.DELETE, first, second), - head: (first, second = {}) => req(methods.HEAD, first, second), + get: (options = {}) => request({ ...options, method: 'GET' }), + post: (options = {}) => request({ ...options, method: 'POST' }), + put: (options = {}) => request({ ...options, method: 'PUT' }), + delete: (options = {}) => request({ ...options, method: 'DELETE' }), + head: (options = {}) => request({ ...options, method: 'HEAD' }), }; diff --git a/tests/integration/api/controllers/replication.spec.js b/tests/integration/api/controllers/replication.spec.js index 77a74fbda20..4aaea1b40d8 100644 --- a/tests/integration/api/controllers/replication.spec.js +++ b/tests/integration/api/controllers/replication.spec.js @@ -202,6 +202,7 @@ describe('replication', () => { // Clean up like normal await utils.revertDb([], true);// And also revert users we created in before await utils.deleteUsers(users, true); + await utils.deletePurgeDbs(); }); afterEach(() => utils.revertDb(DOCS_TO_KEEP, true)); diff --git a/tests/integration/api/server.spec.js b/tests/integration/api/server.spec.js index bc6d056347c..c106fdb878a 100644 --- a/tests/integration/api/server.spec.js +++ b/tests/integration/api/server.spec.js @@ -344,6 +344,8 @@ describe('server', () => { await utils.saveDocs([contact, ...placeMap.values()]); await utils.createUsers([offlineUser]); + await utils.deletePurgeDbs(); + reqOptions = { auth: { username: offlineUser.username, password: offlineUser.password }, }; @@ -362,6 +364,7 @@ describe('server', () => { const reqID = getReqId(apiLogs[0]); const haproxyRequests = haproxyLogs.filter(entry => getReqId(entry) === reqID); + console.warn(haproxyRequests); 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'); diff --git a/tests/utils/index.js b/tests/utils/index.js index b4016c4a9e4..55ce3e6ae1d 100644 --- a/tests/utils/index.js +++ b/tests/utils/index.js @@ -824,6 +824,14 @@ const deleteUsers = async (users, meta = false) => { //NOSONAR } }; +const deletePurgeDbs = async () => { + const dbs = await request({ path: '/_all_dbs' }); + const purgeDbs = dbs.filter(db => db.includes('purged-role')); + for (const purgeDb of purgeDbs) { + await request({ path: `/${purgeDb}`, method: 'DELETE' }); + } +}; + const getCreatedUsers = async () => { const adminUserId = COUCH_USER_ID_PREFIX + constants.USERNAME; const users = await request({ path: `/_users/_all_docs?start_key="${COUCH_USER_ID_PREFIX}"` }); @@ -1687,4 +1695,5 @@ module.exports = { toggleSentinelTransitions, runSentinelTasks, runCommand, + deletePurgeDbs, };