diff --git a/scripts/service-comparison.ts b/scripts/service-comparison.ts index 494b8da1e..6ea8c2c0e 100644 --- a/scripts/service-comparison.ts +++ b/scripts/service-comparison.ts @@ -162,7 +162,7 @@ async function runComparisons(environments = allEnvironments): Promise { const harmonyServiceConfigs = loadServiceConfigs(environment) .filter((config) => config.umm_s); // Ignore any service definitions that do not point to a UMM-S record const ummConceptIds = harmonyServiceConfigs.map((config) => config.umm_s); - const ummRecords = await getServicesByIds(ummConceptIds, null); + const ummRecords = await getServicesByIds({ id: 'harmony-service-comparison-script' }, ummConceptIds, null); const ummRecordsMap = createUmmRecordsMap(ummRecords); for (const harmonyConfig of harmonyServiceConfigs) { const ummRecord = ummRecordsMap[harmonyConfig.umm_s]; diff --git a/services/harmony/app/frontends/capabilities.ts b/services/harmony/app/frontends/capabilities.ts index 7e7777263..6ccb863b6 100644 --- a/services/harmony/app/frontends/capabilities.ts +++ b/services/harmony/app/frontends/capabilities.ts @@ -73,7 +73,7 @@ async function loadCollectionInfo(req: HarmonyRequest): Promise { } else if (collectionid && shortname) { throw new RequestValidationError('Must specify only one of collectionId or shortName, not both'); } else if (collectionid) { - collections = await getCollectionsByIds([collectionid], req.accessToken); + collections = await getCollectionsByIds(req.context, [collectionid], req.accessToken); if (collections.length === 0) { const message = `${collectionid} must be a CMR collection identifier, but ` + 'we could not find a matching collection. Please make sure the collection ID ' @@ -82,7 +82,7 @@ async function loadCollectionInfo(req: HarmonyRequest): Promise { } pickedCollection = collections[0]; } else { - collections = await getCollectionsByShortName(shortname, req.accessToken); + collections = await getCollectionsByShortName(req.context, shortname, req.accessToken); if (collections.length === 0) { const message = `Unable to find collection short name ${shortname} in the CMR. Please ` + ' make sure the short name is correct and that you have access to the collection.'; @@ -96,7 +96,7 @@ async function loadCollectionInfo(req: HarmonyRequest): Promise { pickedCollection = harmonyCollection || pickedCollection; } } - pickedCollection.variables = await getVariablesForCollection(pickedCollection, req.accessToken); + pickedCollection.variables = await getVariablesForCollection(req.context, pickedCollection, req.accessToken); return pickedCollection; } diff --git a/services/harmony/app/frontends/service-image-tags.ts b/services/harmony/app/frontends/service-image-tags.ts index 493d9d71d..7d05b1123 100644 --- a/services/harmony/app/frontends/service-image-tags.ts +++ b/services/harmony/app/frontends/service-image-tags.ts @@ -244,7 +244,7 @@ async function validateUserIsInDeployerOrCoreGroup( req: HarmonyRequest, res: Response, ): Promise { const { hasCorePermissions, isServiceDeployer } = await getEdlGroupInformation( - req.user, req.context.logger, + req.context, req.user, ); if (!isServiceDeployer && !hasCorePermissions) { diff --git a/services/harmony/app/frontends/workflow-ui.ts b/services/harmony/app/frontends/workflow-ui.ts index f8a593a92..69f37c859 100644 --- a/services/harmony/app/frontends/workflow-ui.ts +++ b/services/harmony/app/frontends/workflow-ui.ts @@ -551,7 +551,7 @@ export async function getWorkItemsTable( try { const isAdminRoute = req.context.isAdminAccess; const { isAdmin, isLogViewer } = await getEdlGroupInformation( - req.user, req.context.logger, + req.context, req.user, ); const isAdminOrLogViewer = isAdmin || isLogViewer; const job = await getJobIfAllowed(jobID, req.user, isAdmin, req.accessToken, true); @@ -617,7 +617,7 @@ export async function getWorkItemTableRow( const { jobID, id } = req.params; try { const { isAdmin, isLogViewer } = await getEdlGroupInformation( - req.user, req.context.logger, + req.context, req.user, ); const isAdminOrLogViewer = isAdmin || isLogViewer; const job = await getJobIfAllowed(jobID, req.user, isAdmin, req.accessToken, true); @@ -727,7 +727,7 @@ export async function getWorkItemLogs( const { id, jobID } = req.params; try { const { isAdmin, isLogViewer } = await getEdlGroupInformation( - req.user, req.context.logger, + req.context, req.user, ); const isAdminOrLogViewer = isAdmin || isLogViewer; if (!isAdminOrLogViewer) { diff --git a/services/harmony/app/middleware/cmr-collection-reader.ts b/services/harmony/app/middleware/cmr-collection-reader.ts index c0a0de7a0..5d089363d 100644 --- a/services/harmony/app/middleware/cmr-collection-reader.ts +++ b/services/harmony/app/middleware/cmr-collection-reader.ts @@ -5,6 +5,7 @@ import { ForbiddenError, NotFoundError, ServerError } from '../util/errors'; import HarmonyRequest from '../models/harmony-request'; import { listToText } from '@harmony/util/string'; import { EdlUserEulaInfo, verifyUserEula } from '../util/edl-api'; +import RequestContext from '../models/request-context'; // CMR Collection IDs separated by delimiters of single "+" or single whitespace // (some clients may translate + to space) @@ -20,13 +21,14 @@ const EDR_COLLECTION_ROUTE_REGEX = /^\/ogc-api-edr\/.*\/collections\/(.*)\//; * Loads the variables for the given collection from the CMR and sets the collection's * "variables" attribute to the result * + * @param context - The context for the user's request * @param collection - The collection whose variables should be loaded * @param token - Access token for user request * @returns Resolves when the loading completes */ -async function loadVariablesForCollection(collection: CmrCollection, token: string): Promise { +async function loadVariablesForCollection(context: RequestContext, collection: CmrCollection, token: string): Promise { const c = collection; // We are mutating collection - c.variables = await getVariablesForCollection(collection, token); + c.variables = await getVariablesForCollection(context, collection, token); } /** @@ -41,7 +43,7 @@ async function verifyEulaAcceptance(collections: CmrCollection[], req: HarmonyRe for (const collection of collections) { if (collection.eula_identifiers) { for (const eulaId of collection.eula_identifiers) { - const eulaInfo: EdlUserEulaInfo = await verifyUserEula(req.user, eulaId, req.context.logger); + const eulaInfo: EdlUserEulaInfo = await verifyUserEula(req.context, req.user, eulaId); if (eulaInfo.statusCode == 404 && eulaInfo.acceptEulaUrl) { // EULA wasn't accepted acceptEulaUrls.push(eulaInfo.acceptEulaUrl); } else if (eulaInfo.statusCode == 404) { @@ -96,7 +98,7 @@ async function cmrCollectionReader(req: HarmonyRequest, res, next: NextFunction) req.collectionIds = collectionIds; req.context.logger.info(`Matched CMR concept IDs: ${collectionIds}`); - req.collections = await getCollectionsByIds(collectionIds, req.accessToken); + req.collections = await getCollectionsByIds(req.context, collectionIds, req.accessToken); const { collections } = req; await verifyEulaAcceptance(collections, req); @@ -118,7 +120,7 @@ async function cmrCollectionReader(req: HarmonyRequest, res, next: NextFunction) const promises = []; for (const collection of collections) { - promises.push(loadVariablesForCollection(collection, req.accessToken)); + promises.push(loadVariablesForCollection(req.context, collection, req.accessToken)); } await Promise.all(promises); } else { @@ -133,7 +135,7 @@ async function cmrCollectionReader(req: HarmonyRequest, res, next: NextFunction) shortName = shortNameMatch[2].substr(1, shortNameMatch[2].length - 2); } - const collections = await getCollectionsByShortName(shortName, req.accessToken); + const collections = await getCollectionsByShortName(req.context, shortName, req.accessToken); let pickedCollection = collections[0]; if (collections.length > 1) { // If there are multiple collections matching prefer a collection that is configured @@ -146,7 +148,7 @@ async function cmrCollectionReader(req: HarmonyRequest, res, next: NextFunction) req.collections = [pickedCollection]; req.collectionIds = [pickedCollection.id]; - await loadVariablesForCollection(pickedCollection, req.accessToken); + await loadVariablesForCollection(req.context, pickedCollection, req.accessToken); if (collections.length > 1) { const collectionLandingPage = `${cmrApiConfig.baseURL}/concepts/${pickedCollection.id}`; req.context.messages.push(`There were ${collections.length} collections that matched the` diff --git a/services/harmony/app/middleware/cmr-granule-locator.ts b/services/harmony/app/middleware/cmr-granule-locator.ts index cadac264b..bc905045c 100644 --- a/services/harmony/app/middleware/cmr-granule-locator.ts +++ b/services/harmony/app/middleware/cmr-granule-locator.ts @@ -194,6 +194,7 @@ async function cmrGranuleLocatorTurbo( if ( req.context.serviceConfig.steps[0].image.match('harmonyservices/query-cmr:.*') ) { cmrQuery.collection_concept_id = source.collection; const { hits, sessionKey } = await queryGranulesWithSearchAfter( + req.context, req.accessToken, maxGranules, cmrQuery, @@ -280,6 +281,7 @@ async function cmrGranuleLocatorNonTurbo( cmrQuery.geojson = operation.geojson; } cmrResponse = await queryGranulesForCollection( + req.context, source.collection, cmrQuery, req.accessToken, diff --git a/services/harmony/app/middleware/cmr-umm-collection-reader.ts b/services/harmony/app/middleware/cmr-umm-collection-reader.ts index 2ce198463..0fdfe4f18 100644 --- a/services/harmony/app/middleware/cmr-umm-collection-reader.ts +++ b/services/harmony/app/middleware/cmr-umm-collection-reader.ts @@ -13,7 +13,7 @@ async function cmrUmmCollectionReader(req: HarmonyRequest, res, next: NextFuncti try { const hasUmmConditional = req.context.serviceConfig?.steps?.filter((s) => s.conditional?.umm_c); if (hasUmmConditional && hasUmmConditional.length > 0) { - req.operation.ummCollections = await getUmmCollectionsByIds(req.collectionIds, req.accessToken); + req.operation.ummCollections = await getUmmCollectionsByIds(req.context, req.collectionIds, req.accessToken); } next(); } catch (error) { diff --git a/services/harmony/app/middleware/earthdata-login-token-authorizer.ts b/services/harmony/app/middleware/earthdata-login-token-authorizer.ts index c7ae7d6e3..0e775518b 100644 --- a/services/harmony/app/middleware/earthdata-login-token-authorizer.ts +++ b/services/harmony/app/middleware/earthdata-login-token-authorizer.ts @@ -21,11 +21,10 @@ export default function buildEdlAuthorizer(paths: Array = []): if (authHeader) { const match = authHeader.match(BEARER_TOKEN_REGEX); if (match) { - const { logger } = req.context; const userToken = match[1]; try { // Get the username for the provided token from EDL - const username = await getUserIdRequest(userToken, logger); + const username = await getUserIdRequest(req.context, userToken); req.user = username; req.accessToken = userToken; req.authorized = true; diff --git a/services/harmony/app/middleware/permission-groups.ts b/services/harmony/app/middleware/permission-groups.ts index 22376bb29..e84eb4135 100644 --- a/services/harmony/app/middleware/permission-groups.ts +++ b/services/harmony/app/middleware/permission-groups.ts @@ -17,7 +17,7 @@ export async function admin( req: HarmonyRequest, res: Response, next: NextFunction, ): Promise { try { - const { isAdmin } = await getEdlGroupInformation(req.user, req.context.logger); + const { isAdmin } = await getEdlGroupInformation(req.context, req.user); if (isAdmin) { req.context.isAdminAccess = true; next(); @@ -43,7 +43,7 @@ export async function core( req: HarmonyRequest, res: Response, next: NextFunction, ): Promise { try { - const { hasCorePermissions } = await getEdlGroupInformation(req.user, req.context.logger); + const { hasCorePermissions } = await getEdlGroupInformation(req.context, req.user); if (hasCorePermissions) { req.context.isCoreAccess = true; next(); diff --git a/services/harmony/app/models/job.ts b/services/harmony/app/models/job.ts index 747a087bf..b864f32b8 100644 --- a/services/harmony/app/models/job.ts +++ b/services/harmony/app/models/job.ts @@ -990,6 +990,7 @@ export class Job extends DBRecord implements JobRecord { */ async collectionsHaveEulaRestriction(accessToken: string): Promise { const cmrCollections = await getCollectionsByIds( + { 'id': this.requestId }, this.collectionIds, accessToken, CmrTagKeys.HasEula, @@ -1007,7 +1008,7 @@ export class Job extends DBRecord implements JobRecord { * @returns true or false */ async collectionsHaveGuestReadRestriction(accessToken: string): Promise { - const permissionsMap: CmrPermissionsMap = await getPermissions(this.collectionIds, accessToken); + const permissionsMap: CmrPermissionsMap = await getPermissions({ 'id': this.requestId }, this.collectionIds, accessToken); return this.collectionIds.some((collectionId) => ( !permissionsMap[collectionId] || !(permissionsMap[collectionId].indexOf(CmrPermission.Read) > -1))); diff --git a/services/harmony/app/util/cmr.ts b/services/harmony/app/util/cmr.ts index 13116f3c6..d6e6af3be 100644 --- a/services/harmony/app/util/cmr.ts +++ b/services/harmony/app/util/cmr.ts @@ -11,6 +11,7 @@ import env from './env'; import logger from './log'; import { UmmSpatial } from './spatial/umm-spatial'; import { isValidUri } from './url'; +import RequestContext from '../models/request-context'; const { cmrEndpoint, cmrMaxPageSize, clientId, stagingBucket } = env; @@ -351,6 +352,17 @@ export interface CmrUmmCollectionsResponse extends CmrResponse { function _makeTokenHeader(token: string): object { return cmrApiConfig.useToken && token ? { Authorization: `Bearer ${token}` } : {}; } + +/** + * Create a X-Request-Id header for the given request ID + * + * @param requestId - The request ID for the user's request + * @returns An object with an 'X-Request-Id' key and and id as the value + */ +function makeXRequestIdHeader(requestId: string): object { + return { 'X-Request-Id': requestId }; +} + /** * Handle any errors in the CMR response * @@ -374,6 +386,7 @@ function _handleCmrErrors(response: Response): void { /** * Performs a CMR GET at the given path with the given query string * + * @param context - Information related to the user's request * @param path - The absolute path on the CMR API to the resource being queried * @param query - The key/value pairs to send to the CMR query string * @param token - Access token for user request @@ -381,12 +394,13 @@ function _handleCmrErrors(response: Response): void { * @returns The CMR query result */ export async function cmrGetBase( - path: string, query: CmrQuery, token: string, extraHeaders = {}, + context: RequestContext, path: string, query: CmrQuery, token: string, extraHeaders = {}, ): Promise { const querystr = querystring.stringify(query); const headers = { ...clientIdHeader, ..._makeTokenHeader(token), + ...makeXRequestIdHeader(context.id), ...acceptJsonHeader, ...extraHeaders, }; @@ -403,6 +417,7 @@ export async function cmrGetBase( * Performs a CMR GET at the given path with the given query string. This function wraps * `cmrGetBase` to make it easier to test. * + * @param context - Information related to the user's request * @param path - The absolute path on the CMR API to the resource being queried * @param query - The key/value pairs to send to the CMR query string * @param token - Access token for user request @@ -410,9 +425,9 @@ export async function cmrGetBase( * @throws CmrError - If the CMR returns an error status */ async function _cmrGet( - path: string, query: CmrQuery, token: string, + context: RequestContext, path: string, query: CmrQuery, token: string, ): Promise { - const response = await cmrGetBase(path, query, token); + const response = await cmrGetBase(context, path, query, token); _handleCmrErrors(response); return response; } @@ -423,18 +438,23 @@ async function _cmrGet( * uploads to the CMR. By pulling it into a separate function we can stub it to have * the necessary response. * + * @param context - Information related to the user's request * @param path - The URL path * @param formData - A FormData object or string body to be POST'd * @param headers - The headers to be sent with the POST * @returns A SuperAgent Response object */ export async function fetchPost( - path: string, formData: FormData | string, headers: { [key: string]: string }, + context: RequestContext, path: string, formData: FormData | string, headers: { [key: string]: string }, ): Promise { + const fullHeaders = { + ...headers, + ...makeXRequestIdHeader(context.id), + }; const response: CmrResponse = await fetch(`${cmrApiConfig.baseURL}${path}`, { method: 'POST', body: formData, - headers, + headers: fullHeaders, }); response.data = await response.json(); @@ -521,6 +541,7 @@ function handleWildcards(formData: FormData, form: CmrQuery, field: string): voi /** * Post a query to the CMR with the parameters in the given form * + * @param context - Information related to the user's request * @param path - The absolute path on the CMR API to the resource being queried * @param form - An object with keys and values representing the parameters for the query * @param token - Access token for the user @@ -528,6 +549,7 @@ function handleWildcards(formData: FormData, form: CmrQuery, field: string): voi * @returns The CMR query result */ export async function cmrPostBase( + context: RequestContext, path: string, form: object, token: string, @@ -561,7 +583,7 @@ export async function cmrPostBase( }; try { - const response = await module.exports.fetchPost(path, formData, headers); + const response = await module.exports.fetchPost(context, path, formData, headers); return response; } finally { if (shapefile) { @@ -579,6 +601,7 @@ export async function cmrPostBase( * Post a query to the CMR with the parameters in the given form. This function wraps * `CmrPostBase` to make it easier to test. * + * @param context - Information related to the user's request * @param path - The absolute path on the cmR API to the resource being queried * @param form - An object with keys and values representing the parameters for the query * @param token - Access token for the user @@ -587,12 +610,17 @@ export async function cmrPostBase( * @throws CmrError - If the CMR returns an error status */ async function _cmrPost( + context: RequestContext, path: string, form: CmrQuery, token: string, extraHeaders = {}, ): Promise { - const response = await module.exports.cmrPostBase(path, form, token, extraHeaders); + const headers = { + ...extraHeaders, + 'X-Request-Id': context.id, + }; + const response = await module.exports.cmrPostBase(context, path, form, token, headers); _handleCmrErrors(response); return response; @@ -631,14 +659,15 @@ async function _cmrPostBody( * Performs a CMR variables.json search with the given query string. If there are more * than 2000 variables, page through the variable results until all are retrieved. * + * @param context - Information related to the user's request * @param query - The key/value pairs to search * @param token - Access token for user request * @returns The variable search results */ export async function getAllVariables( - query: CmrQuery, token: string, + context: RequestContext, query: CmrQuery, token: string, ): Promise> { - const variablesResponse = await _cmrPost('/search/variables.umm_json_v1_8_1', query, token) as CmrVariablesResponse; + const variablesResponse = await _cmrPost(context, '/search/variables.umm_json_v1_8_1', query, token) as CmrVariablesResponse; const { hits } = variablesResponse.data; let variables = variablesResponse.data.items; let numVariablesRetrieved = variables.length; @@ -648,7 +677,7 @@ export async function getAllVariables( page_num += 1; logger.debug(`Paging through variables = ${page_num}, numVariablesRetrieved = ${numVariablesRetrieved}, total hits ${hits}`); query.page_num = page_num; - const response = await _cmrPost('/search/variables.umm_json_v1_8_1', query, token) as CmrVariablesResponse; + const response = await _cmrPost(context, '/search/variables.umm_json_v1_8_1', query, token) as CmrVariablesResponse; const pageOfVariables = response.data.items; variables = variables.concat(pageOfVariables); numVariablesRetrieved += pageOfVariables.length; @@ -665,14 +694,15 @@ export async function getAllVariables( * Performs a CMR services.umm_json search with the given query string. If there are more * than 2000 services, page through the service results until all are retrieved. * + * @param context - Information related to the user's request * @param query - The key/value pairs to search * @param token - Access token for user request * @returns The services search results */ export async function getAllServices( - query: CmrQuery, token: string, + context: RequestContext, query: CmrQuery, token: string, ): Promise> { - const servicesResponse = await _cmrPost('/search/services.umm_json_v1_5_2', query, token) as CmrServicesResponse; + const servicesResponse = await _cmrPost(context, '/search/services.umm_json_v1_5_2', query, token) as CmrServicesResponse; const { hits } = servicesResponse.data; let services = servicesResponse.data.items; let numServicesRetrieved = services.length; @@ -682,7 +712,7 @@ export async function getAllServices( page_num += 1; logger.debug(`Paging through services = ${page_num}, numServicesRetrieved = ${numServicesRetrieved}, total hits ${hits}`); query.page_num = page_num; - const response = await _cmrPost('/search/services.umm_json_v1_5_2', query, token) as CmrServicesResponse; + const response = await _cmrPost(context, '/search/services.umm_json_v1_5_2', query, token) as CmrServicesResponse; const pageOfServices = response.data.items; services = services.concat(pageOfServices); numServicesRetrieved += pageOfServices.length; @@ -698,48 +728,52 @@ export async function getAllServices( /** * Performs a CMR collections.json search with the given query string * + * @param context - Information related to the user's request * @param query - The key/value pairs to search * @param token - Access token for user request * @returns The collection search results */ async function queryCollections( - query: CmrQuery, token: string, + context: RequestContext, query: CmrQuery, token: string, ): Promise> { - const collectionsResponse = await _cmrGet('/search/collections.json', query, token) as CmrCollectionsResponse; + const collectionsResponse = await _cmrGet(context, '/search/collections.json', query, token) as CmrCollectionsResponse; return collectionsResponse.data.feed.entry; } /** * Performs a CMR collections.umm_json search with the given query string * + * @param context - Information related to the user's request * @param query - The key/value pairs to search * @param token - Access token for user request * @returns The umm collection search results */ async function queryUmmCollections( - query: CmrQuery, token: string, + context: RequestContext, query: CmrQuery, token: string, ): Promise> { - const ummResponse = await _cmrGet('/search/collections.umm_json_v1_17_3', query, token) as CmrUmmCollectionsResponse; + const ummResponse = await _cmrGet(context, '/search/collections.umm_json_v1_17_3', query, token) as CmrUmmCollectionsResponse; return ummResponse.data.items; } /** * Performs a CMR grids.umm_json search with the given query string * + * @param context - Information related to the user's request * @param query - The key/value pairs to search * @param token - Access token for user request * @returns The grid search results */ async function queryGrids( - query: CmrQuery, token: string, + context: RequestContext, query: CmrQuery, token: string, ): Promise> { - const gridsResponse = await _cmrGet('/search/grids.umm_json', query, token) as CmrGridsResponse; + const gridsResponse = await _cmrGet(context, '/search/grids.umm_json', query, token) as CmrGridsResponse; return gridsResponse.data.items; } /** * Performs a CMR granules.json search with the given form data * + * @param context - Information related to the user's request * @param form - The key/value pairs to search including a `shapefile` parameter * pointing to a file on the file system * @param token - Access token for user request @@ -747,6 +781,7 @@ async function queryGrids( * @returns The granule search results */ export async function queryGranuleUsingMultipartForm( + context: RequestContext, form: CmrQuery, token: string, extraHeaders = {}, @@ -754,9 +789,9 @@ export async function queryGranuleUsingMultipartForm( ): Promise { let granuleResponse = null; if (resultFormat === 'json') { - granuleResponse = await _cmrPost(`/search/granules.${resultFormat}`, form, token, extraHeaders) as CmrGranulesResponse; + granuleResponse = await _cmrPost(context, `/search/granules.${resultFormat}`, form, token, extraHeaders) as CmrGranulesResponse; } else { - granuleResponse = await _cmrPost(`/search/granules.${resultFormat}`, form, token, extraHeaders) as CmrUmmGranulesResponse; + granuleResponse = await _cmrPost(context, `/search/granules.${resultFormat}`, form, token, extraHeaders) as CmrUmmGranulesResponse; } const cmrHits = parseInt(granuleResponse.headers.get('cmr-hits'), 10); const searchAfter = granuleResponse.headers.get('cmr-search-after'); @@ -770,12 +805,14 @@ export async function queryGranuleUsingMultipartForm( /** * Queries and returns the CMR JSON collections corresponding to the given CMR Collection IDs * + * @param context - Information related to the user's request * @param ids - The collection IDs to find * @param token - Access token for user request * @param includeTags - Include tags with tag_key matching this value * @returns The collections with the given ids */ -export function getCollectionsByIds( +export async function getCollectionsByIds( + context: RequestContext, ids: Array, token: string, includeTags?: string, @@ -787,18 +824,20 @@ export function getCollectionsByIds( page_size: cmrMaxPageSize, }, }; - return queryCollections(query, token); + return queryCollections(context, query, token); } /** * Queries and returns the CMR UMM JSON collections corresponding to the given CMR Collection IDs * + * @param context - Information related to the user's request * @param ids - The collection IDs to find * @param token - Access token for user request * @param includeTags - Include tags with tag_key matching this value * @returns The umm collections with the given ids */ -export function getUmmCollectionsByIds( +export async function getUmmCollectionsByIds( + context: RequestContext, ids: Array, token: string, ): Promise> { @@ -806,24 +845,27 @@ export function getUmmCollectionsByIds( concept_id: ids, page_size: cmrMaxPageSize, }; - return queryUmmCollections(query, token); + return queryUmmCollections(context, query, token); } /** * Queries and returns the CMR JSON collections corresponding to the given collection short names * + * @param context - Information related to the user's request * @param shortName - The collection short name to search for * @param token - Access token for user request * @returns The collections with the given ids */ -export function getCollectionsByShortName( - shortName: string, token: string, +export async function getCollectionsByShortName( + context: RequestContext, shortName: string, token: string, ): Promise> { - return queryCollections({ - short_name: shortName, - page_size: cmrMaxPageSize, - sort_key: '-revisionDate', - }, token); + return queryCollections( + context, + { + short_name: shortName, + page_size: cmrMaxPageSize, + sort_key: '-revisionDate', + }, token); } // We have an environment variable called CMR_MAX_PAGE_SIZE which is used for how many items @@ -835,33 +877,38 @@ const ACTUAL_CMR_MAX_PAGE_SIZE = 2000; /** * Queries and returns the CMR JSON variables corresponding to the given CMR Variable IDs * + * @param context - Information related to the user's request * @param ids - The variable IDs to find * @param token - Access token for user request * @returns The variables with the given ids */ -export function getVariablesByIds( +export async function getVariablesByIds( + context: RequestContext, ids: Array, token: string, ): Promise> { - return getAllVariables({ - concept_id: ids, - page_size: ACTUAL_CMR_MAX_PAGE_SIZE, - }, token); + return getAllVariables( + context, + { + concept_id: ids, + page_size: ACTUAL_CMR_MAX_PAGE_SIZE, + }, token); } /** * Queries and returns the CMR JSON variables that are associated with the given CMR JSON collection * + * @param context - Information related to the user's request * @param collection - The collection whose variables should be returned * @param token - Access token for user request * @returns The variables associated with the input collection */ export async function getVariablesForCollection( - collection: CmrCollection, token: string, + context: RequestContext, collection: CmrCollection, token: string, ): Promise> { const varIds = collection.associations && collection.associations.variables; if (varIds) { - return getVariablesByIds(varIds, token); + return getVariablesByIds(context, varIds, token); } return []; } @@ -869,31 +916,36 @@ export async function getVariablesForCollection( /** * Queries and returns the CMR JSON services corresponding to the given CMR UMM Service IDs * + * @param context - Information related to the user's request * @param ids - The CMR concept IDs for the services to find * @param token - Access token for user request * @returns The services with the given ids */ -export function getServicesByIds( +export async function getServicesByIds( + context: RequestContext, ids: Array, token: string, ): Promise> { - return getAllServices({ - concept_id: ids, - page_size: ACTUAL_CMR_MAX_PAGE_SIZE, - }, token); + return getAllServices( + context, + { + concept_id: ids, + page_size: ACTUAL_CMR_MAX_PAGE_SIZE, + }, token); } /** * Queries the CMR grids for grid(s) with the given name. Ideally only one grid should match. * + * @param context - Information related to the user's request * @param gridName - The name of the grid for which to search * @param token - Access token for user request * @returns an array of UMM Grids matching the passed in name (ideally only one item) */ export async function getGridsByName( - gridName: string, token: string, + context: RequestContext, gridName: string, token: string, ): Promise> { - return queryGrids({ name: gridName, page_size: ACTUAL_CMR_MAX_PAGE_SIZE }, token); + return queryGrids(context, { name: gridName, page_size: ACTUAL_CMR_MAX_PAGE_SIZE }, token); } /** @@ -909,16 +961,18 @@ function s3UrlForStoredQueryParams(sessionKey: string): string { /** * Queries and returns the CMR JSON granules for the given search after values and session key. * - * @param collectionId - The ID of the collection whose granules should be searched - * @param query - The CMR granule query parameters to pass + * @param context - Information related to the user's request * @param token - Access token for user request * @param limit - The maximum number of granules to return in this page of results + * @param query - The CMR granule query parameters to pass * @param sessionKey - Key used to look up query parameters * @param searchAfterHeader - Value string to use for the cmr-search-after header + * @param resultFormat - Desired output format for the query - defaults to `json` * @returns A CmrGranuleHits object containing the granules associated with the input collection * and a session key and cmr-search-after header */ export async function queryGranulesWithSearchAfter( + context: RequestContext, token: string, limit: number, query?: CmrQuery, @@ -940,6 +994,7 @@ export async function queryGranulesWithSearchAfter( const storedQuery = await defaultObjectStore().getObjectJson(url); const fullQuery = { ...baseQuery, ...storedQuery }; response = await queryGranuleUsingMultipartForm( + context, fullQuery, token, headers, @@ -953,6 +1008,7 @@ export async function queryGranulesWithSearchAfter( await defaultObjectStore().upload(JSON.stringify(query), url); const fullQuery = { ...baseQuery, ...query }; response = await queryGranuleUsingMultipartForm( + context, fullQuery, token, {}, @@ -970,6 +1026,7 @@ export async function queryGranulesWithSearchAfter( * Queries and returns the CMR JSON granules for the given collection ID with the given query * params. Uses multipart/form-data POST to accommodate large queries and shapefiles. * + * @param context - Information related to the user's request * @param collectionId - The ID of the collection whose granules should be searched * @param query - The CMR granule query parameters to pass * @param token - Access token for user request @@ -977,17 +1034,19 @@ export async function queryGranulesWithSearchAfter( * @returns The granules associated with the input collection */ export function queryGranulesForCollection( - collectionId: string, query: CmrQuery, token: string, limit = 10, + context: RequestContext, collectionId: string, query: CmrQuery, token: string, limit = 10, ): Promise { const baseQuery = { collection_concept_id: collectionId, page_size: Math.min(limit, cmrMaxPageSize), }; - const result: unknown = queryGranuleUsingMultipartForm({ - ...baseQuery, - ...query, - }, token); + const result: unknown = queryGranuleUsingMultipartForm( + context, + { + ...baseQuery, + ...query, + }, token); return result as Promise; } @@ -996,6 +1055,7 @@ export function queryGranulesForCollection( /** * Queries and returns the CMR permissions for each concept specified * + * @param context - Information related to the user's request * @param ids - Check the user permissions for these concept IDs * @param token - Access token for user request * @param username - Check the collection permissions for this user, @@ -1003,6 +1063,7 @@ export function queryGranulesForCollection( * @returns The CmrPermissionsMap which maps concept id to a permissions array */ export async function getPermissions( + context: RequestContext, ids: Array, token: string, username?: string, @@ -1014,7 +1075,7 @@ export async function getPermissions( const query: CmrAclQuery = username ? { user_id: username, ...baseQuery } : { user_type: 'guest', ...baseQuery }; - const permissionsResponse = await _cmrGet('/access-control/permissions', query, token) as CmrPermissionsResponse; + const permissionsResponse = await _cmrGet(context, '/access-control/permissions', query, token) as CmrPermissionsResponse; return permissionsResponse.data; } diff --git a/services/harmony/app/util/edl-api.ts b/services/harmony/app/util/edl-api.ts index fa8b76717..2a2d6a2fd 100644 --- a/services/harmony/app/util/edl-api.ts +++ b/services/harmony/app/util/edl-api.ts @@ -2,11 +2,12 @@ import * as axios from 'axios'; import { hasCookieSecret } from './cookie-secret'; import { ForbiddenError } from './errors'; import { Response } from 'express'; -import { Logger } from 'winston'; import env from './env'; import HarmonyRequest from '../models/harmony-request'; import { oauthOptions } from '../middleware/earthdata-login-oauth-authorizer'; import { ClientCredentials, AccessToken } from 'simple-oauth2'; +import RequestContext from '../models/request-context'; +import { Logger } from 'winston'; const edlUserRequestUrl = `${env.oauthHost}/oauth/tokens/user`; const edlUserGroupsBaseUrl = `${env.oauthHost}/api/user_groups/groups_for_user`; @@ -27,6 +28,7 @@ export async function getClientCredentialsToken(logger: Logger): Promise oauth2 = new ClientCredentials(oauthOptions); } if (harmonyClientToken === undefined || harmonyClientToken.expired()) { + // There appears to be no way to pass in an `X-Request-Id` header with a call to `getToken` harmonyClientToken = await oauth2.getToken({}); } return harmonyClientToken.token.access_token as string; @@ -41,15 +43,16 @@ export async function getClientCredentialsToken(logger: Logger): Promise * Makes a request to the EDL users endpoint to validate a token and return the user ID * associated with that token. * + * @param context - Information related to the user's request * @param userToken - The user's token - * @param logger - The logger associated with the request * @returns the username associated with the token * @throws ForbiddenError if the token is invalid */ -export async function getUserIdRequest(userToken: string, logger: Logger) +export async function getUserIdRequest(context: RequestContext, userToken: string) : Promise { + const { logger } = context; try { - const clientToken = await getClientCredentialsToken(logger); + const clientToken = await getClientCredentialsToken(context.logger); const response = await axios.default.post( edlUserRequestUrl, null, @@ -58,7 +61,7 @@ export async function getUserIdRequest(userToken: string, logger: Logger) client_id: env.oauthClientId, token: userToken, }, - headers: { authorization: `Bearer ${clientToken}` }, + headers: { authorization: `Bearer ${clientToken}`, 'X-Request-Id': context.id }, }, ); return response.data.uid; @@ -72,16 +75,17 @@ export async function getUserIdRequest(userToken: string, logger: Logger) /** * Returns the groups to which a user belongs * + * @param context - Information related to the user's request * @param username - The EDL username - * @param logger - The logger associated with the request * @returns the groups to which the user belongs */ -async function getUserGroups(username: string, logger: Logger) +async function getUserGroups(context: RequestContext, username: string) : Promise { + const { logger } = context; try { - const clientToken = await getClientCredentialsToken(logger); + const clientToken = await getClientCredentialsToken(context.logger); const response = await axios.default.get( - `${edlUserGroupsBaseUrl}/${username}`, { headers: { Authorization: `Bearer ${clientToken}` } }, + `${edlUserGroupsBaseUrl}/${username}`, { headers: { Authorization: `Bearer ${clientToken}`, 'X-Request-Id': context.id } }, ); const groups = response.data?.user_groups.map((group) => group.group_id) || []; return groups; @@ -102,14 +106,14 @@ export interface EdlGroupMembership { /** * Returns the harmony relevant group information for a user with two keys isAdmin and isLogViewer. * + * @param context - Information related to the user's request * @param username - The EDL username - * @param logger - The logger associated with the request * @returns A promise which resolves to info about whether the user is an admin, log viewer or service deployer, * and has core permissions (e.g. allowing user to access server configuration endpoints) */ -export async function getEdlGroupInformation(username: string, logger: Logger) +export async function getEdlGroupInformation(context: RequestContext, username: string) : Promise { - const groups = await getUserGroups(username, logger); + const groups = await getUserGroups(context, username); let isAdmin = false; if (groups.includes(env.adminGroupId)) { isAdmin = true; @@ -139,7 +143,7 @@ export async function getEdlGroupInformation(username: string, logger: Logger) */ export async function isAdminUser(req: HarmonyRequest): Promise { const isAdmin = req.context.isAdminAccess || - (await getEdlGroupInformation(req.user, req.context.logger)).isAdmin; + (await getEdlGroupInformation(req.context, req.user)).isAdmin; return isAdmin; } @@ -152,20 +156,20 @@ export interface EdlUserEulaInfo { /** * Check whether the user has accepted a EULA. * + * @param context - Information related to the user's request * @param username - The EDL username * @param eulaId - The id of the EULA (from the collection metadata) - * @param logger - The logger associated with the request * @returns A promise which resolves to info about whether the user has accepted a EULA, * and if not, where they can go to accept it */ -export async function verifyUserEula(username: string, eulaId: string, logger: Logger) +export async function verifyUserEula(context: RequestContext, username: string, eulaId: string) : Promise { let statusCode: number; let eulaResponse: { msg: string, error: string, accept_eula_url: string }; try { - const clientToken = await getClientCredentialsToken(logger); + const clientToken = await getClientCredentialsToken(context.logger); const response = await axios.default.get( - edlVerifyUserEulaUrl(username, eulaId), { headers: { Authorization: `Bearer ${clientToken}` } }, + edlVerifyUserEulaUrl(username, eulaId), { headers: { Authorization: `Bearer ${clientToken}`, 'X-Request-Id': context.id } }, ); eulaResponse = response.data; statusCode = response.status; @@ -192,7 +196,7 @@ export async function validateUserIsInCoreGroup( // if request has cookie-secret header, it is in the core permissions group if (! hasCookieSecret(req)) { const { hasCorePermissions } = await getEdlGroupInformation( - req.user, req.context.logger, + req.context, req.user, ); if (!hasCorePermissions) { diff --git a/services/harmony/app/util/grids.ts b/services/harmony/app/util/grids.ts index 4e55d4ebf..875b205ad 100644 --- a/services/harmony/app/util/grids.ts +++ b/services/harmony/app/util/grids.ts @@ -41,7 +41,7 @@ export async function parseGridMiddleware( try { validateNoConflictingGridParameters(query); const gridName = query.grid; - const grids = await getGridsByName(gridName, req.accessToken ); + const grids = await getGridsByName(req.context, gridName, req.accessToken ); if (grids.length > 1) { req.context.logger.warn(`Multiple grids returned for name ${gridName}, choosing the first one returned by CMR.`); } else if (grids.length == 0) { diff --git a/services/harmony/test/helpers/caching-hooks.ts b/services/harmony/test/helpers/caching-hooks.ts index 77c66d3e2..56fd3ca28 100644 --- a/services/harmony/test/helpers/caching-hooks.ts +++ b/services/harmony/test/helpers/caching-hooks.ts @@ -3,6 +3,7 @@ import { before, after } from 'mocha'; import { stub, SinonStub } from 'sinon'; import { hookGetQueueForType, hookGetQueueForUrl, hookGetQueueUrlForService, hookGetWorkSchedulerQueue, hookProcessSchedulerQueue } from './queue'; import * as cmr from '../../app/util/cmr'; +import RequestContext from '../../app/models/request-context'; hookGetQueueForType(); hookGetQueueForUrl(); @@ -11,7 +12,10 @@ hookGetQueueUrlForService(); hookProcessSchedulerQueue(); process.env.REPLAY = process.env.REPLAY || 'record'; -require('replay'); +import replay from 'replay'; +// Update replay.headers to avoid recording 'X-Request-Id', but still record other 'X-*' headers +replay.headers = replay.headers.filter((re) => re.toString() != /^x-/.toString()); +replay.headers.push(/^(?!x-request-id$)x-/); // Patch our requests so they work repeatably in node-replay with multipart form // data. @@ -39,7 +43,7 @@ before(function () { // Stub fetchPost to provide a string body rather than a FormData stream stub(cmr, 'fetchPost').callsFake(async function ( - path: string, formData: FormData, headers: { [key: string]: string }, + context: RequestContext, path: string, formData: FormData, headers: { [key: string]: string }, ): Promise { // Read the body into a stream const chunks = []; @@ -49,7 +53,7 @@ before(function () { formData.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); formData.resume(); }); - return originalFetchPost(path, body, headers); + return originalFetchPost(context, path, body, headers); }); }); diff --git a/services/harmony/test/util/cmr.ts b/services/harmony/test/util/cmr.ts index 39c7326f5..7d036f6e7 100644 --- a/services/harmony/test/util/cmr.ts +++ b/services/harmony/test/util/cmr.ts @@ -2,12 +2,16 @@ import { describe, it } from 'mocha'; import { expect } from 'chai'; import { CmrRelatedUrl, CmrUmmVariable, getVariablesByIds, getAllVariables, CmrQuery, queryGranuleUsingMultipartForm } from '../../app/util/cmr'; +const fakeContext = { + id: '1234', +}; + describe('util/cmr', function () { describe('getVariablesByIds', function () { it('returns a valid response, given a huge number of variables', async function () { const validVariableId = 'V1233801695-EEDTEST'; const ids = [...Array(300).keys()].map((num) => `V${num}-YOCLOUD`).concat(validVariableId); - const variables = await getVariablesByIds(ids, ''); + const variables = await getVariablesByIds(fakeContext, ids, ''); expect(variables.length).to.eql(1); }); @@ -21,7 +25,7 @@ describe('util/cmr', function () { Format: 'XML', MimeType: 'application/XML', }]; - const redVariable: CmrUmmVariable = (await getVariablesByIds(['V1233801695-EEDTEST'], ''))[0]; + const redVariable: CmrUmmVariable = (await getVariablesByIds(fakeContext, ['V1233801695-EEDTEST'], ''))[0]; const redVariableRelatedUrls: CmrRelatedUrl[] = redVariable.umm.RelatedURLs; expect(expectedRelatedUrls).to.deep.equal(redVariableRelatedUrls); }); @@ -34,7 +38,7 @@ describe('util/cmr', function () { concept_id: variableIds, page_size: 1, // requires paging through 4 pages }; - const variables = await getAllVariables(query, ''); + const variables = await getAllVariables(fakeContext, query, ''); expect(variables.length).to.eql(4); }); }); @@ -45,14 +49,14 @@ describe('util/cmr', function () { concept_id: 'C1233800302-EEDTEST', readable_granule_name: '*oceania_east', }; - const results = await queryGranuleUsingMultipartForm(query, ''); + const results = await queryGranuleUsingMultipartForm(fakeContext, query, ''); expect(results.hits).to.equal(16); const querySingleMatch: CmrQuery = { concept_id: 'C1233800302-EEDTEST', readable_granule_name: '*01_08_7f00ff_oceania_east', }; - const singleMatchResults = await queryGranuleUsingMultipartForm(querySingleMatch, ''); + const singleMatchResults = await queryGranuleUsingMultipartForm(fakeContext, querySingleMatch, ''); expect(singleMatchResults.hits).to.equal(1); }); @@ -61,14 +65,14 @@ describe('util/cmr', function () { concept_id: 'C1233800302-EEDTEST', readable_granule_name: '001_*_east', }; - const results = await queryGranuleUsingMultipartForm(query, ''); + const results = await queryGranuleUsingMultipartForm(fakeContext, query, ''); expect(results.hits).to.equal(2); const querySingleMatch: CmrQuery = { concept_id: 'C1233800302-EEDTEST', readable_granule_name: '001_*_7f00ff_oceania_east', }; - const singleMatchResults = await queryGranuleUsingMultipartForm(querySingleMatch, ''); + const singleMatchResults = await queryGranuleUsingMultipartForm(fakeContext, querySingleMatch, ''); expect(singleMatchResults.hits).to.equal(1); }); @@ -77,14 +81,14 @@ describe('util/cmr', function () { concept_id: 'C1233800302-EEDTEST', readable_granule_name: '001_*', }; - const results = await queryGranuleUsingMultipartForm(query, ''); + const results = await queryGranuleUsingMultipartForm(fakeContext, query, ''); expect(results.hits).to.equal(12); const querySingleMatch: CmrQuery = { concept_id: 'C1233800302-EEDTEST', readable_granule_name: '001_08_7f00ff_oceania_eas*', }; - const singleMatchResults = await queryGranuleUsingMultipartForm(querySingleMatch, ''); + const singleMatchResults = await queryGranuleUsingMultipartForm(fakeContext, querySingleMatch, ''); expect(singleMatchResults.hits).to.equal(1); }); @@ -93,7 +97,7 @@ describe('util/cmr', function () { concept_id: 'C1233800302-EEDTEST', readable_granule_name: '?01_08_7f00ff_oceania_east', }; - const results = await queryGranuleUsingMultipartForm(query, ''); + const results = await queryGranuleUsingMultipartForm(fakeContext, query, ''); expect(results.hits).to.equal(1); }); @@ -102,7 +106,7 @@ describe('util/cmr', function () { concept_id: 'C1233800302-EEDTEST', readable_granule_name: '001_08_7f00ff_?ceania_east', }; - const results = await queryGranuleUsingMultipartForm(query, ''); + const results = await queryGranuleUsingMultipartForm(fakeContext, query, ''); expect(results.hits).to.equal(1); }); @@ -111,7 +115,7 @@ describe('util/cmr', function () { concept_id: 'C1233800302-EEDTEST', readable_granule_name: '001_08_7f00ff_oceania_eas?', }; - const results = await queryGranuleUsingMultipartForm(query, ''); + const results = await queryGranuleUsingMultipartForm(fakeContext, query, ''); expect(results.hits).to.equal(1); }); }); diff --git a/services/harmony/test/util/edl-api.ts b/services/harmony/test/util/edl-api.ts index e804c61c0..f73e93844 100644 --- a/services/harmony/test/util/edl-api.ts +++ b/services/harmony/test/util/edl-api.ts @@ -3,7 +3,6 @@ import { expect } from 'chai'; import { getEdlGroupInformation, } from '../../app/util/edl-api'; -import logger from '../../app/util/log'; import { stubEdlRequest, token, unstubEdlRequest } from '../helpers/auth'; describe('util/edl-api', function () { @@ -20,19 +19,19 @@ describe('util/edl-api', function () { }); describe('when the user is not part of the service deployers group', function () { it('returns isServiceDeployer:false', async function () { - const groups = await getEdlGroupInformation('joe', logger); + const groups = await getEdlGroupInformation({ id: '1234' }, 'joe'); expect(groups.isServiceDeployer).is.false; }); }); describe('when the user is part of the service deployers and log viewers group', function () { it('returns isServiceDeployer:true', async function () { - const groups = await getEdlGroupInformation('eve', logger); + const groups = await getEdlGroupInformation({ id: '1234' }, 'eve'); expect(groups.isServiceDeployer).is.true; }); }); describe('when the user is part of the service deployers group', function () { it('returns isServiceDeployer:true', async function () { - const groups = await getEdlGroupInformation('buzz', logger); + const groups = await getEdlGroupInformation({ id: '1234' }, 'buzz'); expect(groups.isServiceDeployer).is.true; }); }); diff --git a/services/harmony/test/workflow-ui/work-items-table.ts b/services/harmony/test/workflow-ui/work-items-table.ts index 70e35431b..099e4ff94 100644 --- a/services/harmony/test/workflow-ui/work-items-table.ts +++ b/services/harmony/test/workflow-ui/work-items-table.ts @@ -227,7 +227,7 @@ describe('Workflow UI work items table route', function () { expect(listing).to.contain(mustache.render( `{{#labels}} {{.}} - {{/labels}}`, + {{/labels}}`, { labels: targetJob.labels })); expect(listing).to.contain('job-url-text'); expect(listing).to.contain('copy-request'); diff --git a/services/query-cmr/app/query.ts b/services/query-cmr/app/query.ts index 8425ffedb..c6534761c 100644 --- a/services/query-cmr/app/query.ts +++ b/services/query-cmr/app/query.ts @@ -14,6 +14,7 @@ export interface DataSource { /** * Queries a single page of CMR granules using search after parameters, generating a STAC catalog for * each granule in the page. + * @param requestId - The request ID of the job associated with this search * @param token - The token to use for the query * @param scrollId - Scroll session id used in the CMR-Scroll-Id header for granule search * @param maxCmrGranules - The maximum size of the page to request from CMR @@ -25,6 +26,7 @@ export interface DataSource { * cmr hits. */ async function querySearchAfter( + requestId: string, token: string, scrollId: string, maxCmrGranules: number, @@ -36,6 +38,7 @@ async function querySearchAfter( [sessionKey, searchAfter] = scrollId.split(':', 2); } const cmrResponse = await queryGranulesWithSearchAfter( + { 'id': requestId }, token, maxCmrGranules, null, @@ -103,7 +106,7 @@ export async function queryGranules( ): Promise<[number, number[], StacCatalog[], string, number]> { const { unencryptedAccessToken } = operation; const [totalItemsSize, outputItemSizes, catalogs, newScrollId, hits] = - await querySearchAfter(unencryptedAccessToken, scrollId, maxCmrGranules, logger); + await querySearchAfter(operation.requestId, unencryptedAccessToken, scrollId, maxCmrGranules, logger); return [totalItemsSize, outputItemSizes, catalogs, newScrollId, hits]; } diff --git a/services/query-cmr/test/query-granules.ts b/services/query-cmr/test/query-granules.ts index 13ec8f1e6..6f33046c0 100644 --- a/services/query-cmr/test/query-granules.ts +++ b/services/query-cmr/test/query-granules.ts @@ -16,6 +16,7 @@ import { CmrError } from '../../harmony/app/util/errors'; chai.use(require('chai-as-promised')); const operation = new DataOperation({ + requestId: 'aaaaaaaa-bbbb-1234-cccc-dddddddddddd', unencryptedAccessToken: 'shhhhh!', sources: [{ collection: 'C001-TEST' }, { collection: 'C002-TEST' }], }); @@ -48,7 +49,7 @@ async function formDataToString(formdata: CombinedStream): Promise { * @returns key/value pairs of form data name to value */ async function fetchPostArgsToFields( - [_, formdata], + [_a, _b, formdata], ): Promise { // eslint-disable-line @typescript-eslint/no-explicit-any const data = (await formDataToString(formdata)).replace(/----+[0-9]+-*\r\n/g, ''); const result = {};