From 699b180a77ad40df516cc8d6717bb0744ced9549 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Tue, 1 Sep 2026 18:28:13 -0400 Subject: [PATCH 01/18] MMT-4199: Initial checkin --- .../src/createOrUpdateConcept/handler.js | 85 +++++++++++++++++ serverless/src/deleteConcept/handler.js | 91 +++++++++++++++++++ .../src/getConcept/__tests__/handler.test.js | 0 serverless/src/getConcept/handler.js | 87 ++++++++++++++++++ .../src/getConcepts/__tests__/handler.test.js | 0 serverless/src/getConcepts/handler.js | 89 ++++++++++++++++++ .../__tests__/getConceptsBucketName.test.js | 10 ++ serverless/src/utils/getConceptsBucketName.js | 10 ++ sharedConstants/s3ConceptTypes.js | 3 + 9 files changed, 375 insertions(+) create mode 100644 serverless/src/createOrUpdateConcept/handler.js create mode 100644 serverless/src/deleteConcept/handler.js create mode 100644 serverless/src/getConcept/__tests__/handler.test.js create mode 100644 serverless/src/getConcept/handler.js create mode 100644 serverless/src/getConcepts/__tests__/handler.test.js create mode 100644 serverless/src/getConcepts/handler.js create mode 100644 serverless/src/utils/__tests__/getConceptsBucketName.test.js create mode 100644 serverless/src/utils/getConceptsBucketName.js create mode 100644 sharedConstants/s3ConceptTypes.js diff --git a/serverless/src/createOrUpdateConcept/handler.js b/serverless/src/createOrUpdateConcept/handler.js new file mode 100644 index 000000000..e442096f1 --- /dev/null +++ b/serverless/src/createOrUpdateConcept/handler.js @@ -0,0 +1,85 @@ +import { PutObjectCommand } from '@aws-sdk/client-s3' + +import { getApplicationConfig } from '../../../sharedUtils/getConfig' +import { getS3Client } from '../utils/getS3Client' +import { getConceptsBucketName } from '../utils/getConceptsBucketName' +import { s3ConceptTypes } from '../../../sharedConstants/s3ConceptTypes' +import fetchProviders from '../utils/fetchProviders' + +let s3Client + +/** + * Update (overwrite) a concept in S3 + * @param {Object} event Details about the HTTP request that it received + */ +const createOrUpdateConcept = async (event) => { + const { defaultResponseHeaders } = getApplicationConfig() + const conceptsBucketName = getConceptsBucketName() + + if (s3Client == null) { + s3Client = getS3Client() + } + + const { body, pathParameters } = event + const { conceptType, nativeId, providerId } = pathParameters + + if (!s3ConceptTypes.includes(conceptType)) { + console.error(`Invalid conceptType "${conceptType}"`) + + return { + statusCode: 400, + headers: defaultResponseHeaders + } + } + + try { + const providerIds = await fetchProviders(event) + + if (!providerIds.includes(providerId)) { + console.error(`Missing permissions for provider "${providerId}"`) + + return { + statusCode: 401, + headers: defaultResponseHeaders + } + } + } catch (error) { + console.log('Error fetching providers:', error) + + return { + statusCode: 500, + headers: defaultResponseHeaders + } + } + + try { + // S3 directory structure: s3BucketName/providerId/conceptType/nativeId.json + const key = `${providerId}/${conceptType}/${nativeId}.json` + + // PutObject overwrites any existing object at this key + const putCommand = new PutObjectCommand({ + Bucket: conceptsBucketName, + Body: body, + Key: key + }) + + const response = await s3Client.send(putCommand) + + const { $metadata: metadata } = response + const { httpStatusCode: statusCode } = metadata + + return { + statusCode, + headers: defaultResponseHeaders + } + } catch (error) { + console.log('updateConcept Error:', error) + + return { + statusCode: 404, + headers: defaultResponseHeaders + } + } +} + +export default createOrUpdateConcept diff --git a/serverless/src/deleteConcept/handler.js b/serverless/src/deleteConcept/handler.js new file mode 100644 index 000000000..55cc79b05 --- /dev/null +++ b/serverless/src/deleteConcept/handler.js @@ -0,0 +1,91 @@ +import { DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3' + +import { getApplicationConfig } from '../../../sharedUtils/getConfig' +import { getS3Client } from '../utils/getS3Client' +import { getConceptsBucketName } from '../utils/getConceptsBucketName' +import { s3ConceptTypes } from '../../../sharedConstants/s3ConceptTypes' +import fetchProviders from '../utils/fetchProviders' + +let s3Client + +/** + * Delete a concept from S3 + * @param {Object} event Details about the HTTP request that it received + */ +const deleteConcept = async (event) => { + const { defaultResponseHeaders } = getApplicationConfig() + + if (s3Client == null) { + s3Client = getS3Client() + } + + const { pathParameters } = event + const { conceptType, nativeId, providerId } = pathParameters + + if (!s3ConceptTypes.includes(conceptType)) { + console.error(`Invalid conceptType "${conceptType}"`) + + return { + statusCode: 400, + headers: defaultResponseHeaders + } + } + + try { + const providerIds = await fetchProviders(event) + + if (!providerIds.includes(providerId)) { + console.error(`Missing permissions for provider "${providerId}"`) + + return { + statusCode: 401, + headers: defaultResponseHeaders + } + } + + // S3 directory structure: s3BucketName/providerId/conceptType/nativeId.json + const key = `${providerId}/${conceptType}/${nativeId}.json` + const conceptsBucketName = getConceptsBucketName() + + // DeleteObject is idempotent and won't error on a missing key, so check existence first + try { + await s3Client.send(new HeadObjectCommand({ + Bucket: conceptsBucketName, + Key: key + })) + } catch (headError) { + console.error(`Concept not found for key "${key}"`, headError) + + return { + statusCode: 404, + headers: defaultResponseHeaders + } + } + + // Delete the file from S3 + const deleteCommand = new DeleteObjectCommand({ + Bucket: conceptsBucketName, + Key: key + }) + + const response = await s3Client.send(deleteCommand) + + const { $metadata: metadata } = response + + const { httpStatusCode: statusCode } = metadata + + return { + statusCode, + headers: defaultResponseHeaders + } + } catch (error) { + console.log('deleteConcept Error:', error) + + return { + statusCode: 404, + headers: defaultResponseHeaders + } + } +} + +export default deleteConcept diff --git a/serverless/src/getConcept/__tests__/handler.test.js b/serverless/src/getConcept/__tests__/handler.test.js new file mode 100644 index 000000000..e69de29bb diff --git a/serverless/src/getConcept/handler.js b/serverless/src/getConcept/handler.js new file mode 100644 index 000000000..59d6fb5a3 --- /dev/null +++ b/serverless/src/getConcept/handler.js @@ -0,0 +1,87 @@ +import { GetObjectCommand } from '@aws-sdk/client-s3' + +import { getApplicationConfig } from '../../../sharedUtils/getConfig' +import { getS3Client } from '../utils/getS3Client' +import { getConceptsBucketName } from '../utils/getConceptsBucketName' +import { s3ConceptTypes } from '../../../sharedConstants/s3ConceptTypes' +import fetchProviders from '../utils/fetchProviders' + +let s3Client + +/** + * Retrieve a concept from S3 + * @param {Object} event Details about the HTTP request that it received + */ +const getConcept = async (event) => { + const { defaultResponseHeaders } = getApplicationConfig() + + if (s3Client == null) { + s3Client = getS3Client() + } + + const { pathParameters } = event + const { conceptType, nativeId, providerId } = pathParameters + + if (!s3ConceptTypes.includes(conceptType)) { + console.error(`Invalid conceptType "${conceptType}"`) + + return { + statusCode: 400, + headers: defaultResponseHeaders + } + } + + try { + const providerIds = await fetchProviders(event) + + if (!providerIds.includes(providerId)) { + console.error(`Missing permissions for provider "${providerId}"`) + + return { + statusCode: 401, + headers: defaultResponseHeaders + } + } + + // S3 directory structure: s3BucketName/providerId/conceptType/nativeId.json + const key = `${providerId}/${conceptType}/${nativeId}.json` + + // Retrieve the file from S3 + const conceptsBucketName = getConceptsBucketName() + const getCommand = new GetObjectCommand({ + Bucket: conceptsBucketName, + Key: key + }) + + const response = await s3Client.send(getCommand) + + const { $metadata: metadata } = response + + const { httpStatusCode: statusCode } = metadata + + // Transform the body into a string to return + const { Body: responseBody } = response + + const body = { + concept: JSON.parse(await responseBody.transformToString()), + conceptType, + nativeId, + providerId + } + + return { + body: JSON.stringify(body), + statusCode, + headers: defaultResponseHeaders + } + } catch (error) { + console.log('getConcept Error:', error) + + return { + statusCode: 404, + headers: defaultResponseHeaders + } + } +} + +export default getConcept diff --git a/serverless/src/getConcepts/__tests__/handler.test.js b/serverless/src/getConcepts/__tests__/handler.test.js new file mode 100644 index 000000000..e69de29bb diff --git a/serverless/src/getConcepts/handler.js b/serverless/src/getConcepts/handler.js new file mode 100644 index 000000000..c098658ba --- /dev/null +++ b/serverless/src/getConcepts/handler.js @@ -0,0 +1,89 @@ +import { getApplicationConfig } from '../../../sharedUtils/getConfig' +import { s3ListObjects } from '../utils/s3ListObjects' +import { getS3Client } from '../utils/getS3Client' +import { getConceptsBucketName } from '../utils/getConceptsBucketName' +import { s3ConceptTypes } from '../../../sharedConstants/s3ConceptTypes' +import fetchProviders from '../utils/fetchProviders' + +let s3Client + +/** + * Retrieve a list of concepts from S3 + * @param {Object} event Details about the HTTP request that it received + */ +const getConcepts = async (event) => { + const { defaultResponseHeaders } = getApplicationConfig() + + if (s3Client == null) { + s3Client = getS3Client() + } + + const { pathParameters } = event + const { conceptType, providerId } = pathParameters || {} + + if (!s3ConceptTypes.includes(conceptType)) { + console.error(`Invalid conceptType "${conceptType}"`) + + return { + statusCode: 400, + headers: defaultResponseHeaders + } + } + + // S3 directory structure: s3BucketName/providerId/conceptType/nativeId.json + // Since both providerId and conceptType are known, list directly under that prefix + const prefix = `${providerId}/${conceptType}/` + const bucketName = getConceptsBucketName() + + try { + const allowedProviderIds = await fetchProviders(event) + + if (!allowedProviderIds.includes(providerId)) { + return { + statusCode: 404, + headers: defaultResponseHeaders + } + } + + const objectList = await s3ListObjects(s3Client, prefix, bucketName) + + const body = objectList.map((object) => { + const [, , fileName] = object.Key.split('/') + + // Strip the `.json` extension to recover the nativeId + const nativeId = fileName.replace(/\.json$/, '') + + return { + conceptType, + lastModified: object.LastModified, + nativeId, + providerId + } + }) + + const sortedBody = body.sort((a, b) => { + const nativeIdA = a.nativeId.toUpperCase() + const nativeIdB = b.nativeId.toUpperCase() + + if (nativeIdA < nativeIdB) return -1 + if (nativeIdA > nativeIdB) return 1 + + return 0 + }) + + return { + body: JSON.stringify(sortedBody), + statusCode: 200, + headers: defaultResponseHeaders + } + } catch (error) { + console.log('getConcepts Error:', error) + + return { + statusCode: 404, + headers: defaultResponseHeaders + } + } +} + +export default getConcepts diff --git a/serverless/src/utils/__tests__/getConceptsBucketName.test.js b/serverless/src/utils/__tests__/getConceptsBucketName.test.js new file mode 100644 index 000000000..2946f288b --- /dev/null +++ b/serverless/src/utils/__tests__/getConceptsBucketName.test.js @@ -0,0 +1,10 @@ +import { getConceptsBucketName } from '../getConceptsBucketName' + +describe('getConceptsBucketName', () => { + test('returns the default when offline', () => { + process.env.IS_OFFLINE = true + const bucketName = getConceptsBucketName() + + expect(bucketName).toBe('mmt-concepts-bucket-local') + }) +}) diff --git a/serverless/src/utils/getConceptsBucketName.js b/serverless/src/utils/getConceptsBucketName.js new file mode 100644 index 000000000..42e7796a6 --- /dev/null +++ b/serverless/src/utils/getConceptsBucketName.js @@ -0,0 +1,10 @@ +/** + * Returns the concepts bucket name + */ +export const getConceptsBucketName = () => { + if (process.env.IS_OFFLINE) { + return 'mmt-concepts-bucket-local' + } + + return process.env.CONCEPTS_BUCKET_NAME +} diff --git a/sharedConstants/s3ConceptTypes.js b/sharedConstants/s3ConceptTypes.js new file mode 100644 index 000000000..739241c8c --- /dev/null +++ b/sharedConstants/s3ConceptTypes.js @@ -0,0 +1,3 @@ +export const s3ConceptTypes = [ + 'collections' +] From 319afa00873f1733243ac96f08c1163d68a77da1 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Wed, 2 Sep 2026 13:34:20 -0400 Subject: [PATCH 02/18] MMT-4199: Add routes for lambda entries --- cdk/mmt/lib/mmt-functions.ts | 72 +++++++++++++++++++ .../lib/mmt-shared-api-gateway-resources.ts | 20 ++++++ .../src/createOrUpdateConcept/handler.js | 9 +++ 3 files changed, 101 insertions(+) diff --git a/cdk/mmt/lib/mmt-functions.ts b/cdk/mmt/lib/mmt-functions.ts index c077af3e0..819ff01fd 100644 --- a/cdk/mmt/lib/mmt-functions.ts +++ b/cdk/mmt/lib/mmt-functions.ts @@ -250,5 +250,77 @@ export class MmtFunctions extends Construct { functionNamePrefix, role: s3LambdaRole }) + + // getConcepts - GET /providers/{providerId}/{conceptType} + new application.NodeJsFunction(new cdk.NestedStack(scope, 'GetConceptsNestedStack'), 'GetConceptsLambda', { + ...defaultLambdaConfig, + api: { + apiGatewayDeployment, + apiGatewayResource: resources.providersConceptTypeResource, + apiGatewayRestApi, + authorizer: authorizers.edlAuthorizer, + methods: ['GET'], + parentPath: 'providersProviderIdVar', + path: '{conceptType}' + }, + entry: '../../serverless/src/getConcepts/handler.js', + functionName: 'getConcepts', + functionNamePrefix, + role: s3LambdaRole + }) + + // getConcept - GET /providers/{providerId}/{conceptType}/{nativeId} + new application.NodeJsFunction(new cdk.NestedStack(scope, 'GetConceptNestedStack'), 'GetConceptLambda', { + ...defaultLambdaConfig, + api: { + apiGatewayDeployment, + apiGatewayResource: resources.providersConceptTypeNativeIdResource, + apiGatewayRestApi, + authorizer: authorizers.edlAuthorizer, + methods: ['GET'], + parentPath: 'providersProviderIdVarConceptTypeVar', + path: '{nativeId}' + }, + entry: '../../serverless/src/getConcept/handler.js', + functionName: 'getConcept', + functionNamePrefix, + role: s3LambdaRole + }) + + // createOrUpdateConcept - PUT /providers/{providerId}/{conceptType}/{nativeId} + new application.NodeJsFunction(new cdk.NestedStack(scope, 'CreateOrUpdateConceptNestedStack'), 'CreateOrUpdateConceptLambda', { + ...defaultLambdaConfig, + api: { + apiGatewayDeployment, + apiGatewayResource: resources.providersConceptTypeNativeIdResource, + apiGatewayRestApi, + authorizer: authorizers.edlAuthorizer, + methods: ['PUT'], + parentPath: 'providersProviderIdVarConceptTypeVar', + path: '{nativeId}' + }, + entry: '../../serverless/src/createOrUpdateConcept/handler.js', + functionName: 'createOrUpdateConcept', + functionNamePrefix, + role: s3LambdaRole + }) + + // deleteConcept - DELETE /providers/{providerId}/{conceptType}/{nativeId} + new application.NodeJsFunction(new cdk.NestedStack(scope, 'DeleteConceptNestedStack'), 'DeleteConceptLambda', { + ...defaultLambdaConfig, + api: { + apiGatewayDeployment, + apiGatewayResource: resources.providersConceptTypeNativeIdResource, + apiGatewayRestApi, + authorizer: authorizers.edlAuthorizer, + methods: ['DELETE'], + parentPath: 'providersProviderIdVarConceptTypeVar', + path: '{nativeId}' + }, + entry: '../../serverless/src/deleteConcept/handler.js', + functionName: 'deleteConcept', + functionNamePrefix, + role: s3LambdaRole + }) } } diff --git a/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts b/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts index f554a3241..14ec1c4fb 100644 --- a/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts +++ b/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts @@ -25,6 +25,8 @@ export class MmtApiResources extends Construct { public readonly errorLoggerResource: apigateway.CfnResource public readonly gkrKeywordRecommendationsResource: apigateway.CfnResource public readonly gkrSendFeedbackResource: apigateway.CfnResource + public readonly providersConceptTypeResource: apigateway.CfnResource + public readonly providersConceptTypeNativeIdResource: apigateway.CfnResource public readonly providersTemplatesResource: apigateway.CfnResource public readonly providersTemplatesIdResource: apigateway.CfnResource public readonly templatesResource: apigateway.CfnResource @@ -118,6 +120,20 @@ export class MmtApiResources extends Construct { }) this.providersTemplatesIdResource = providersTemplatesIdResource + const providersConceptTypeResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceProvidersProviderIdVarConceptTypeVar', { + parentId: providerIdResource.ref, + pathPart: '{conceptType}', + restApiId: apiGatewayRestApi.ref + }) + this.providersConceptTypeResource = providersConceptTypeResource + + const providersConceptTypeNativeIdResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceProvidersProviderIdVarConceptTypeVarNativeIdVar', { + parentId: providersConceptTypeResource.ref, + pathPart: '{nativeId}', + restApiId: apiGatewayRestApi.ref + }) + this.providersConceptTypeNativeIdResource = providersConceptTypeNativeIdResource + const templatesResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceTemplates', { parentId: apiGatewayRestApi.attrRootResourceId, pathPart: 'templates', @@ -140,5 +156,9 @@ export class MmtApiResources extends Construct { addOptions('TemplatesIdVar', templatesIdResource, ['GET']) addOptions('Templates', templatesResource, ['GET']) + + addOptions('ProvidersProviderIdVarConceptTypeVar', providersConceptTypeResource, ['GET']) + + addOptions('ProvidersProviderIdVarConceptTypeVarNativeIdVar', providersConceptTypeNativeIdResource, ['GET', 'PUT', 'DELETE']) } } diff --git a/serverless/src/createOrUpdateConcept/handler.js b/serverless/src/createOrUpdateConcept/handler.js index e442096f1..515d0eadf 100644 --- a/serverless/src/createOrUpdateConcept/handler.js +++ b/serverless/src/createOrUpdateConcept/handler.js @@ -23,6 +23,15 @@ const createOrUpdateConcept = async (event) => { const { body, pathParameters } = event const { conceptType, nativeId, providerId } = pathParameters + if (!body) { + console.error('Missing request body') + + return { + statusCode: 400, + headers: defaultResponseHeaders + } + } + if (!s3ConceptTypes.includes(conceptType)) { console.error(`Invalid conceptType "${conceptType}"`) From 5342578d8e1e56c72930185e04ca34be36023aec Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Wed, 2 Sep 2026 14:28:17 -0400 Subject: [PATCH 03/18] MMT-4199: Add tests --- .../__tests__/handler.test.js | 147 ++++++++++++++++ .../deleteConcept/__tests__/handler.test.js | 159 +++++++++++++++++ .../src/getConcept/__tests__/handler.test.js | 133 ++++++++++++++ .../src/getConcepts/__tests__/handler.test.js | 166 ++++++++++++++++++ 4 files changed, 605 insertions(+) create mode 100644 serverless/src/createOrUpdateConcept/__tests__/handler.test.js create mode 100644 serverless/src/deleteConcept/__tests__/handler.test.js diff --git a/serverless/src/createOrUpdateConcept/__tests__/handler.test.js b/serverless/src/createOrUpdateConcept/__tests__/handler.test.js new file mode 100644 index 000000000..f69b7fdb3 --- /dev/null +++ b/serverless/src/createOrUpdateConcept/__tests__/handler.test.js @@ -0,0 +1,147 @@ +import { mockClient } from 'aws-sdk-client-mock' +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3' + +import createOrUpdateConcept from '../handler' + +const s3ClientMock = mockClient(S3Client) + +beforeEach(() => { + vi.clearAllMocks() + s3ClientMock.reset() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +describe('createOrUpdateConcept', () => { + test('saves the concept to s3', async () => { + s3ClientMock.on(PutObjectCommand).resolves({ + $metadata: { + httpStatusCode: 200, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + attempts: 1, + totalRetryDelay: 0 + }, + ETag: '"1a7e08244b933e4fea1f920da4988500"' + }) + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + body: JSON.stringify({ mock: 'Concept Body' }), + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await createOrUpdateConcept(event) + + expect(response.statusCode).toBe(200) + }) + + describe('when the request body is missing', () => { + test('returns a status code 400', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + body: undefined, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await createOrUpdateConcept(event) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('when the conceptType is invalid', () => { + test('returns a status code 400', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + body: JSON.stringify({ mock: 'Concept Body' }), + pathParameters: { + conceptType: 'invalid-type', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await createOrUpdateConcept(event) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('when you do not have authorization to create or update', () => { + test('returns a status code 401', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + body: JSON.stringify({ mock: 'Concept Body' }), + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_3' + } + } + + const response = await createOrUpdateConcept(event) + + expect(response.statusCode).toBe(401) + }) + }) + + describe('when fetching providers throws an error', () => { + test('returns a status code 500', async () => { + const event = { + headers: { + Authorization: 'Bearer invalid_token' + }, + body: JSON.stringify({ mock: 'Concept Body' }), + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await createOrUpdateConcept(event) + + expect(response.statusCode).toBe(500) + }) + }) + + describe('when saving to s3 throws an error', () => { + test('returns a status code 404', async () => { + s3ClientMock.on(PutObjectCommand).rejects(new Error('S3 error')) + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + body: JSON.stringify({ mock: 'Concept Body' }), + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await createOrUpdateConcept(event) + + expect(response.statusCode).toBe(404) + }) + }) +}) diff --git a/serverless/src/deleteConcept/__tests__/handler.test.js b/serverless/src/deleteConcept/__tests__/handler.test.js new file mode 100644 index 000000000..b502e06e5 --- /dev/null +++ b/serverless/src/deleteConcept/__tests__/handler.test.js @@ -0,0 +1,159 @@ +import { mockClient } from 'aws-sdk-client-mock' +import { + DeleteObjectCommand, + HeadObjectCommand, + S3Client +} from '@aws-sdk/client-s3' + +import deleteConcept from '../handler' + +const s3ClientMock = mockClient(S3Client) + +beforeEach(() => { + vi.clearAllMocks() + s3ClientMock.reset() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +describe('deleteConcept', () => { + test('deletes the concept from s3', async () => { + s3ClientMock.on(HeadObjectCommand).resolves({ + $metadata: { + httpStatusCode: 200 + } + }) + + s3ClientMock.on(DeleteObjectCommand).resolves({ + $metadata: { + httpStatusCode: 204, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + attempts: 1, + totalRetryDelay: 0 + } + }) + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await deleteConcept(event) + + expect(response.statusCode).toBe(204) + }) + + describe('when the conceptType is invalid', () => { + test('returns a status code 400', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'invalid-type', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await deleteConcept(event) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('when you do not have authorization to delete', () => { + test('returns a status code 401', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_3' + } + } + + const response = await deleteConcept(event) + + expect(response.statusCode).toBe(401) + }) + }) + + describe('when fetching providers throws an error', () => { + test('returns a status code 404', async () => { + const event = { + headers: { + Authorization: 'Bearer invalid_token' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await deleteConcept(event) + + expect(response.statusCode).toBe(404) + }) + }) + + describe('when the concept does not exist in s3', () => { + test('returns a status code 404 and does not attempt to delete', async () => { + s3ClientMock.on(HeadObjectCommand).rejects(new Error('NotFound')) + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await deleteConcept(event) + + expect(response.statusCode).toBe(404) + expect(s3ClientMock.commandCalls(DeleteObjectCommand)).toHaveLength(0) + }) + }) + + describe('when deleting the object in s3 throws an error', () => { + test('returns a status code 404', async () => { + s3ClientMock.on(HeadObjectCommand).resolves({ + $metadata: { + httpStatusCode: 200 + } + }) + + s3ClientMock.on(DeleteObjectCommand).rejects(new Error('S3 error')) + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await deleteConcept(event) + + expect(response.statusCode).toBe(404) + }) + }) +}) diff --git a/serverless/src/getConcept/__tests__/handler.test.js b/serverless/src/getConcept/__tests__/handler.test.js index e69de29bb..343073711 100644 --- a/serverless/src/getConcept/__tests__/handler.test.js +++ b/serverless/src/getConcept/__tests__/handler.test.js @@ -0,0 +1,133 @@ +import { mockClient } from 'aws-sdk-client-mock' +import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3' + +import getConcept from '../handler' + +const s3ClientMock = mockClient(S3Client) + +beforeEach(() => { + vi.clearAllMocks() + s3ClientMock.reset() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +describe('getConcept', () => { + test('retrieves the concept from s3', async () => { + const mockConcept = { mock: 'Concept Body' } + + s3ClientMock.on(GetObjectCommand).resolves({ + $metadata: { + httpStatusCode: 200, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + attempts: 1, + totalRetryDelay: 0 + }, + Body: { + transformToString: vi.fn().mockResolvedValue(JSON.stringify(mockConcept)) + } + }) + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await getConcept(event) + + expect(response.statusCode).toBe(200) + + expect(JSON.parse(response.body)).toEqual({ + concept: mockConcept, + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + }) + }) + + describe('when the conceptType is invalid', () => { + test('returns a status code 400', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'invalid-type', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await getConcept(event) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('when you do not have authorization to retrieve', () => { + test('returns a status code 401', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_3' + } + } + + const response = await getConcept(event) + + expect(response.statusCode).toBe(401) + }) + }) + + describe('when fetching providers throws an error', () => { + test('returns a status code 404', async () => { + const event = { + headers: { + Authorization: 'Bearer invalid_token' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await getConcept(event) + + expect(response.statusCode).toBe(404) + }) + }) + + describe('when the object does not exist in s3', () => { + test('returns a status code 404', async () => { + s3ClientMock.on(GetObjectCommand).rejects(new Error('NoSuchKey')) + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await getConcept(event) + + expect(response.statusCode).toBe(404) + }) + }) +}) diff --git a/serverless/src/getConcepts/__tests__/handler.test.js b/serverless/src/getConcepts/__tests__/handler.test.js index e69de29bb..5df6fdb42 100644 --- a/serverless/src/getConcepts/__tests__/handler.test.js +++ b/serverless/src/getConcepts/__tests__/handler.test.js @@ -0,0 +1,166 @@ +import { s3ListObjects } from '../../utils/s3ListObjects' +import getConcepts from '../handler' + +vi.mock('../../utils/s3ListObjects', () => ({ + s3ListObjects: vi.fn() +})) + +beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +describe('getConcepts', () => { + test('retrieves a sorted list of concepts from s3', async () => { + s3ListObjects.mockResolvedValue([ + { + Key: 'MMT_1/collections/Zebra.json', + LastModified: '2024-01-02T00:00:00.000Z' + }, + { + Key: 'MMT_1/collections/Apple.json', + LastModified: '2024-01-01T00:00:00.000Z' + } + ]) + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + providerId: 'MMT_1' + } + } + + const response = await getConcepts(event) + + expect(response.statusCode).toBe(200) + + expect(JSON.parse(response.body)).toEqual([ + { + conceptType: 'collections', + lastModified: '2024-01-01T00:00:00.000Z', + nativeId: 'Apple', + providerId: 'MMT_1' + }, + { + conceptType: 'collections', + lastModified: '2024-01-02T00:00:00.000Z', + nativeId: 'Zebra', + providerId: 'MMT_1' + } + ]) + }) + + describe('when pathParameters is missing', () => { + test('returns a status code 400', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1' + } + } + + const response = await getConcepts(event) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('when the conceptType is invalid', () => { + test('returns a status code 400', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'invalid-type', + providerId: 'MMT_1' + } + } + + const response = await getConcepts(event) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('when you do not have authorization to list', () => { + test('returns a status code 404', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + providerId: 'MMT_3' + } + } + + const response = await getConcepts(event) + + expect(response.statusCode).toBe(404) + expect(s3ListObjects).not.toHaveBeenCalled() + }) + }) + + describe('when fetching providers throws an error', () => { + test('returns a status code 404', async () => { + const event = { + headers: { + Authorization: 'Bearer invalid_token' + }, + pathParameters: { + conceptType: 'collections', + providerId: 'MMT_1' + } + } + + const response = await getConcepts(event) + + expect(response.statusCode).toBe(404) + }) + }) + + describe('when listing objects in s3 throws an error', () => { + test('returns a status code 404', async () => { + s3ListObjects.mockRejectedValue(new Error('S3 error')) + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + providerId: 'MMT_1' + } + } + + const response = await getConcepts(event) + + expect(response.statusCode).toBe(404) + }) + }) + + describe('when there are no concepts for the provider', () => { + test('returns an empty array', async () => { + s3ListObjects.mockResolvedValue([]) + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + providerId: 'MMT_1' + } + } + + const response = await getConcepts(event) + + expect(response.statusCode).toBe(200) + expect(JSON.parse(response.body)).toEqual([]) + }) + }) +}) From 53250b56d6c5f4ba74b7fab28cb1024d7a6e1624 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Wed, 2 Sep 2026 15:56:48 -0400 Subject: [PATCH 04/18] MMT-4199: Add staging api key check --- .../__tests__/handler.test.js | 100 ++++++++++++++--- .../src/createOrUpdateConcept/handler.js | 18 ++- .../deleteConcept/__tests__/handler.test.js | 104 +++++++++++++++--- serverless/src/deleteConcept/handler.js | 18 ++- .../src/getConcept/__tests__/handler.test.js | 97 ++++++++++++++-- serverless/src/getConcept/handler.js | 18 ++- .../src/getConcepts/__tests__/handler.test.js | 90 ++++++++++++--- serverless/src/getConcepts/handler.js | 18 ++- 8 files changed, 403 insertions(+), 60 deletions(-) diff --git a/serverless/src/createOrUpdateConcept/__tests__/handler.test.js b/serverless/src/createOrUpdateConcept/__tests__/handler.test.js index f69b7fdb3..f687fbe2d 100644 --- a/serverless/src/createOrUpdateConcept/__tests__/handler.test.js +++ b/serverless/src/createOrUpdateConcept/__tests__/handler.test.js @@ -5,11 +5,18 @@ import createOrUpdateConcept from '../handler' const s3ClientMock = mockClient(S3Client) +const validStagingHeaders = { + Authorization: 'Bearer ABC-1', + 'Staging-Api-Key': 'test-staging-key' +} + beforeEach(() => { vi.clearAllMocks() s3ClientMock.reset() vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) + + process.env.STAGING_API_KEY = 'test-staging-key' }) describe('createOrUpdateConcept', () => { @@ -27,9 +34,7 @@ describe('createOrUpdateConcept', () => { }) const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, body: JSON.stringify({ mock: 'Concept Body' }), pathParameters: { conceptType: 'collections', @@ -43,12 +48,84 @@ describe('createOrUpdateConcept', () => { expect(response.statusCode).toBe(200) }) - describe('when the request body is missing', () => { - test('returns a status code 400', async () => { + describe('when the Prod-Staging-Api-Key header is missing', () => { + test('returns a status code 401', async () => { const event = { headers: { Authorization: 'Bearer ABC-1' }, + body: JSON.stringify({ mock: 'Concept Body' }), + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await createOrUpdateConcept(event) + + expect(response.statusCode).toBe(401) + }) + }) + + describe('when the Prod-Staging-Api-Key header does not match', () => { + test('returns a status code 401', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1', + 'Prod-Staging-Api-Key': 'wrong-key' + }, + body: JSON.stringify({ mock: 'Concept Body' }), + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await createOrUpdateConcept(event) + + expect(response.statusCode).toBe(401) + }) + }) + + describe('when the Prod-Staging-Api-Key header has different casing', () => { + test('is still accepted (case-insensitive lookup)', async () => { + s3ClientMock.on(PutObjectCommand).resolves({ + $metadata: { + httpStatusCode: 200, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + attempts: 1, + totalRetryDelay: 0 + }, + ETag: '"1a7e08244b933e4fea1f920da4988500"' + }) + + const event = { + headers: { + Authorization: 'Bearer ABC-1', + 'staging-api-key': 'test-staging-key' + }, + body: JSON.stringify({ mock: 'Concept Body' }), + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await createOrUpdateConcept(event) + + expect(response.statusCode).toBe(200) + }) + }) + + describe('when the request body is missing', () => { + test('returns a status code 400', async () => { + const event = { + headers: validStagingHeaders, body: undefined, pathParameters: { conceptType: 'collections', @@ -66,9 +143,7 @@ describe('createOrUpdateConcept', () => { describe('when the conceptType is invalid', () => { test('returns a status code 400', async () => { const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, body: JSON.stringify({ mock: 'Concept Body' }), pathParameters: { conceptType: 'invalid-type', @@ -86,9 +161,7 @@ describe('createOrUpdateConcept', () => { describe('when you do not have authorization to create or update', () => { test('returns a status code 401', async () => { const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, body: JSON.stringify({ mock: 'Concept Body' }), pathParameters: { conceptType: 'collections', @@ -107,6 +180,7 @@ describe('createOrUpdateConcept', () => { test('returns a status code 500', async () => { const event = { headers: { + ...validStagingHeaders, Authorization: 'Bearer invalid_token' }, body: JSON.stringify({ mock: 'Concept Body' }), @@ -128,9 +202,7 @@ describe('createOrUpdateConcept', () => { s3ClientMock.on(PutObjectCommand).rejects(new Error('S3 error')) const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, body: JSON.stringify({ mock: 'Concept Body' }), pathParameters: { conceptType: 'collections', diff --git a/serverless/src/createOrUpdateConcept/handler.js b/serverless/src/createOrUpdateConcept/handler.js index 515d0eadf..605c829ac 100644 --- a/serverless/src/createOrUpdateConcept/handler.js +++ b/serverless/src/createOrUpdateConcept/handler.js @@ -20,9 +20,25 @@ const createOrUpdateConcept = async (event) => { s3Client = getS3Client() } - const { body, pathParameters } = event + const { body, headers, pathParameters } = event const { conceptType, nativeId, providerId } = pathParameters + // Header casing isn't guaranteed by API Gateway/Lambda proxy integration, + // so look up 'Staging-Api-Key' case-insensitively + const stagingApiKeyHeader = Object.entries(headers || {}) + .find(([headerName]) => headerName.toLowerCase() === 'staging-api-key') + + const [, stagingApiKey] = stagingApiKeyHeader || [] + + if (stagingApiKey !== process.env.STAGING_API_KEY) { + console.error('Missing or invalid Staging-Api-Key header') + + return { + statusCode: 401, + headers: defaultResponseHeaders + } + } + if (!body) { console.error('Missing request body') diff --git a/serverless/src/deleteConcept/__tests__/handler.test.js b/serverless/src/deleteConcept/__tests__/handler.test.js index b502e06e5..bbccccf6c 100644 --- a/serverless/src/deleteConcept/__tests__/handler.test.js +++ b/serverless/src/deleteConcept/__tests__/handler.test.js @@ -9,11 +9,18 @@ import deleteConcept from '../handler' const s3ClientMock = mockClient(S3Client) +const validStagingHeaders = { + Authorization: 'Bearer ABC-1', + 'Staging-Api-Key': 'test-staging-key' +} + beforeEach(() => { vi.clearAllMocks() s3ClientMock.reset() vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) + + process.env.STAGING_API_KEY = 'test-staging-key' }) describe('deleteConcept', () => { @@ -36,9 +43,7 @@ describe('deleteConcept', () => { }) const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, pathParameters: { conceptType: 'collections', nativeId: 'TestNativeId', @@ -51,12 +56,88 @@ describe('deleteConcept', () => { expect(response.statusCode).toBe(204) }) - describe('when the conceptType is invalid', () => { - test('returns a status code 400', async () => { + describe('when the Prod-Staging-Api-Key header is missing', () => { + test('returns a status code 401', async () => { const event = { headers: { Authorization: 'Bearer ABC-1' }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await deleteConcept(event) + + expect(response.statusCode).toBe(401) + expect(s3ClientMock.commandCalls(HeadObjectCommand)).toHaveLength(0) + }) + }) + + describe('when the Prod-Staging-Api-Key header does not match', () => { + test('returns a status code 401', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1', + 'Staging-Api-Key': 'wrong-key' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await deleteConcept(event) + + expect(response.statusCode).toBe(401) + expect(s3ClientMock.commandCalls(HeadObjectCommand)).toHaveLength(0) + }) + }) + + describe('when the Prod-Staging-Api-Key header has different casing', () => { + test('is still accepted (case-insensitive lookup)', async () => { + s3ClientMock.on(HeadObjectCommand).resolves({ + $metadata: { + httpStatusCode: 200 + } + }) + + s3ClientMock.on(DeleteObjectCommand).resolves({ + $metadata: { + httpStatusCode: 204, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + attempts: 1, + totalRetryDelay: 0 + } + }) + + const event = { + headers: { + Authorization: 'Bearer ABC-1', + 'staging-api-key': 'test-staging-key' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await deleteConcept(event) + + expect(response.statusCode).toBe(204) + }) + }) + + describe('when the conceptType is invalid', () => { + test('returns a status code 400', async () => { + const event = { + headers: validStagingHeaders, pathParameters: { conceptType: 'invalid-type', nativeId: 'TestNativeId', @@ -73,9 +154,7 @@ describe('deleteConcept', () => { describe('when you do not have authorization to delete', () => { test('returns a status code 401', async () => { const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, pathParameters: { conceptType: 'collections', nativeId: 'TestNativeId', @@ -93,6 +172,7 @@ describe('deleteConcept', () => { test('returns a status code 404', async () => { const event = { headers: { + ...validStagingHeaders, Authorization: 'Bearer invalid_token' }, pathParameters: { @@ -113,9 +193,7 @@ describe('deleteConcept', () => { s3ClientMock.on(HeadObjectCommand).rejects(new Error('NotFound')) const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, pathParameters: { conceptType: 'collections', nativeId: 'TestNativeId', @@ -141,9 +219,7 @@ describe('deleteConcept', () => { s3ClientMock.on(DeleteObjectCommand).rejects(new Error('S3 error')) const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, pathParameters: { conceptType: 'collections', nativeId: 'TestNativeId', diff --git a/serverless/src/deleteConcept/handler.js b/serverless/src/deleteConcept/handler.js index 55cc79b05..4f60b352f 100644 --- a/serverless/src/deleteConcept/handler.js +++ b/serverless/src/deleteConcept/handler.js @@ -19,9 +19,25 @@ const deleteConcept = async (event) => { s3Client = getS3Client() } - const { pathParameters } = event + const { headers, pathParameters } = event const { conceptType, nativeId, providerId } = pathParameters + // Header casing isn't guaranteed by API Gateway/Lambda proxy integration, + // so look up 'Staging-Api-Key' case-insensitively + const stagingApiKeyHeader = Object.entries(headers || {}) + .find(([headerName]) => headerName.toLowerCase() === 'staging-api-key') + + const [, stagingApiKey] = stagingApiKeyHeader || [] + + if (stagingApiKey !== process.env.STAGING_API_KEY) { + console.error('Missing or invalid Staging-Api-Key header') + + return { + statusCode: 401, + headers: defaultResponseHeaders + } + } + if (!s3ConceptTypes.includes(conceptType)) { console.error(`Invalid conceptType "${conceptType}"`) diff --git a/serverless/src/getConcept/__tests__/handler.test.js b/serverless/src/getConcept/__tests__/handler.test.js index 343073711..95f215a9f 100644 --- a/serverless/src/getConcept/__tests__/handler.test.js +++ b/serverless/src/getConcept/__tests__/handler.test.js @@ -5,11 +5,18 @@ import getConcept from '../handler' const s3ClientMock = mockClient(S3Client) +const validStagingHeaders = { + Authorization: 'Bearer ABC-1', + 'Staging-Api-Key': 'test-staging-key' +} + beforeEach(() => { vi.clearAllMocks() s3ClientMock.reset() vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) + + process.env.STAGING_API_KEY = 'test-staging-key' }) describe('getConcept', () => { @@ -31,9 +38,7 @@ describe('getConcept', () => { }) const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, pathParameters: { conceptType: 'collections', nativeId: 'TestNativeId', @@ -53,12 +58,85 @@ describe('getConcept', () => { }) }) - describe('when the conceptType is invalid', () => { - test('returns a status code 400', async () => { + describe('when the Prod-Staging-Api-Key header is missing', () => { + test('returns a status code 401', async () => { const event = { headers: { Authorization: 'Bearer ABC-1' }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await getConcept(event) + + expect(response.statusCode).toBe(401) + }) + }) + + describe('when the Prod-Staging-Api-Key header does not match', () => { + test('returns a status code 401', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1', + 'Staging-Api-Key': 'wrong-key' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await getConcept(event) + + expect(response.statusCode).toBe(401) + }) + }) + + describe('when the Prod-Staging-Api-Key header has different casing', () => { + test('is still accepted (case-insensitive lookup)', async () => { + const mockConcept = { mock: 'Concept Body' } + + s3ClientMock.on(GetObjectCommand).resolves({ + $metadata: { + httpStatusCode: 200, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + attempts: 1, + totalRetryDelay: 0 + }, + Body: { + transformToString: vi.fn().mockResolvedValue(JSON.stringify(mockConcept)) + } + }) + + const event = { + headers: { + Authorization: 'Bearer ABC-1', + 'staging-api-key': 'test-staging-key' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await getConcept(event) + + expect(response.statusCode).toBe(200) + }) + }) + + describe('when the conceptType is invalid', () => { + test('returns a status code 400', async () => { + const event = { + headers: validStagingHeaders, pathParameters: { conceptType: 'invalid-type', nativeId: 'TestNativeId', @@ -75,9 +153,7 @@ describe('getConcept', () => { describe('when you do not have authorization to retrieve', () => { test('returns a status code 401', async () => { const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, pathParameters: { conceptType: 'collections', nativeId: 'TestNativeId', @@ -95,6 +171,7 @@ describe('getConcept', () => { test('returns a status code 404', async () => { const event = { headers: { + ...validStagingHeaders, Authorization: 'Bearer invalid_token' }, pathParameters: { @@ -115,9 +192,7 @@ describe('getConcept', () => { s3ClientMock.on(GetObjectCommand).rejects(new Error('NoSuchKey')) const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, pathParameters: { conceptType: 'collections', nativeId: 'TestNativeId', diff --git a/serverless/src/getConcept/handler.js b/serverless/src/getConcept/handler.js index 59d6fb5a3..97f858c3a 100644 --- a/serverless/src/getConcept/handler.js +++ b/serverless/src/getConcept/handler.js @@ -19,9 +19,25 @@ const getConcept = async (event) => { s3Client = getS3Client() } - const { pathParameters } = event + const { headers, pathParameters } = event const { conceptType, nativeId, providerId } = pathParameters + // Header casing isn't guaranteed by API Gateway/Lambda proxy integration, + // so look up 'Staging-Api-Key' case-insensitively + const stagingApiKeyHeader = Object.entries(headers || {}) + .find(([headerName]) => headerName.toLowerCase() === 'staging-api-key') + + const [, stagingApiKey] = stagingApiKeyHeader || [] + + if (stagingApiKey !== process.env.STAGING_API_KEY) { + console.error('Missing or invalid Staging-Api-Key header') + + return { + statusCode: 401, + headers: defaultResponseHeaders + } + } + if (!s3ConceptTypes.includes(conceptType)) { console.error(`Invalid conceptType "${conceptType}"`) diff --git a/serverless/src/getConcepts/__tests__/handler.test.js b/serverless/src/getConcepts/__tests__/handler.test.js index 5df6fdb42..7dcd55652 100644 --- a/serverless/src/getConcepts/__tests__/handler.test.js +++ b/serverless/src/getConcepts/__tests__/handler.test.js @@ -5,10 +5,17 @@ vi.mock('../../utils/s3ListObjects', () => ({ s3ListObjects: vi.fn() })) +const validStagingHeaders = { + Authorization: 'Bearer ABC-1', + 'Staging-Api-Key': 'test-staging-key' +} + beforeEach(() => { vi.clearAllMocks() vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) + + process.env.STAGING_API_KEY = 'test-staging-key' }) describe('getConcepts', () => { @@ -25,9 +32,7 @@ describe('getConcepts', () => { ]) const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, pathParameters: { conceptType: 'collections', providerId: 'MMT_1' @@ -54,16 +59,74 @@ describe('getConcepts', () => { ]) }) - describe('when pathParameters is missing', () => { - test('returns a status code 400', async () => { + describe('when the Prod-Staging-Api-Key header is missing', () => { + test('returns a status code 401', async () => { const event = { headers: { Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + providerId: 'MMT_1' + } + } + + const response = await getConcepts(event) + + expect(response.statusCode).toBe(401) + expect(s3ListObjects).not.toHaveBeenCalled() + }) + }) + + describe('when the Prod-Staging-Api-Key header does not match', () => { + test('returns a status code 401', async () => { + const event = { + headers: { + Authorization: 'Bearer ABC-1', + 'Staging-Api-Key': 'wrong-key' + }, + pathParameters: { + conceptType: 'collections', + providerId: 'MMT_1' + } + } + + const response = await getConcepts(event) + + expect(response.statusCode).toBe(401) + expect(s3ListObjects).not.toHaveBeenCalled() + }) + }) + + describe('when the Prod-Staging-Api-Key header has different casing', () => { + test('is still accepted (case-insensitive lookup)', async () => { + s3ListObjects.mockResolvedValue([]) + + const event = { + headers: { + Authorization: 'Bearer ABC-1', + 'staging-api-key': 'test-staging-key' + }, + pathParameters: { + conceptType: 'collections', + providerId: 'MMT_1' } } const response = await getConcepts(event) + expect(response.statusCode).toBe(200) + }) + }) + + describe('when pathParameters is missing', () => { + test('returns a status code 400', async () => { + const event = { + headers: validStagingHeaders + } + + const response = await getConcepts(event) + expect(response.statusCode).toBe(400) }) }) @@ -71,9 +134,7 @@ describe('getConcepts', () => { describe('when the conceptType is invalid', () => { test('returns a status code 400', async () => { const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, pathParameters: { conceptType: 'invalid-type', providerId: 'MMT_1' @@ -89,9 +150,7 @@ describe('getConcepts', () => { describe('when you do not have authorization to list', () => { test('returns a status code 404', async () => { const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, pathParameters: { conceptType: 'collections', providerId: 'MMT_3' @@ -109,6 +168,7 @@ describe('getConcepts', () => { test('returns a status code 404', async () => { const event = { headers: { + ...validStagingHeaders, Authorization: 'Bearer invalid_token' }, pathParameters: { @@ -128,9 +188,7 @@ describe('getConcepts', () => { s3ListObjects.mockRejectedValue(new Error('S3 error')) const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, pathParameters: { conceptType: 'collections', providerId: 'MMT_1' @@ -148,9 +206,7 @@ describe('getConcepts', () => { s3ListObjects.mockResolvedValue([]) const event = { - headers: { - Authorization: 'Bearer ABC-1' - }, + headers: validStagingHeaders, pathParameters: { conceptType: 'collections', providerId: 'MMT_1' diff --git a/serverless/src/getConcepts/handler.js b/serverless/src/getConcepts/handler.js index c098658ba..adcd4a93d 100644 --- a/serverless/src/getConcepts/handler.js +++ b/serverless/src/getConcepts/handler.js @@ -18,9 +18,25 @@ const getConcepts = async (event) => { s3Client = getS3Client() } - const { pathParameters } = event + const { headers, pathParameters } = event const { conceptType, providerId } = pathParameters || {} + // Header casing isn't guaranteed by API Gateway/Lambda proxy integration, + // so look up 'Staging-Api-Key' case-insensitively + const stagingApiKeyHeader = Object.entries(headers || {}) + .find(([headerName]) => headerName.toLowerCase() === 'staging-api-key') + + const [, stagingApiKey] = stagingApiKeyHeader || [] + + if (stagingApiKey !== process.env.STAGING_API_KEY) { + console.error('Missing or invalid Staging-Api-Key header') + + return { + statusCode: 401, + headers: defaultResponseHeaders + } + } + if (!s3ConceptTypes.includes(conceptType)) { console.error(`Invalid conceptType "${conceptType}"`) From d98059c4f40a63ed73217bdfd82ffc23c27031e8 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Wed, 2 Sep 2026 18:49:50 -0400 Subject: [PATCH 05/18] MMT-4199: Add env parameters for key and S3 bucket name --- bin/deploy-bamboo.sh | 2 ++ cdk/mmt/lib/mmt-stack.ts | 4 +++ .../__tests__/getConceptsBucketName.test.js | 2 +- serverless/src/utils/getConceptsBucketName.js | 4 +-- setup/startS3.js | 31 ++++++++++++------- 5 files changed, 28 insertions(+), 15 deletions(-) diff --git a/bin/deploy-bamboo.sh b/bin/deploy-bamboo.sh index b41c29f26..665fe6f39 100755 --- a/bin/deploy-bamboo.sh +++ b/bin/deploy-bamboo.sh @@ -76,6 +76,7 @@ dockerRun() { -e "AWS_SECRET_ACCESS_KEY=$bamboo_AWS_SECRET_ACCESS_KEY" \ -e "AWS_SESSION_TOKEN=$bamboo_AWS_SESSION_TOKEN" \ -e "COLLECTION_TEMPLATES_BUCKET_NAME=${bamboo_COLLECTION_TEMPLATES_BUCKET_NAME}" \ + -e "STAGING_CONCEPTS_BUCKET_NAME=${bamboo_STAGING_CONCEPTS_BUCKET_NAME}" \ -e "COOKIE_DOMAIN=$bamboo_COOKIE_DOMAIN" \ -e "DISPLAY_PROD_WARNING=$bamboo_DISPLAY_PROD_WARNING" \ -e "EDL_CLIENT_ID=$bamboo_EDL_CLIENT_ID" \ @@ -89,6 +90,7 @@ dockerRun() { -e "NODE_OPTIONS=--max_old_space_size=4096" \ -e "SITE_BUCKET=${bamboo_SITE_BUCKET}" \ -e "STAGE_NAME=$bamboo_STAGE_NAME" \ + -e "STAGING_API_KEY=$bamboo_STAGING_API_KEY" \ -e "SUBNET_ID_A=$bamboo_SUBNET_ID_A" \ -e "SUBNET_ID_B=$bamboo_SUBNET_ID_B" \ -e "SUBNET_ID_C=$bamboo_SUBNET_ID_C" \ diff --git a/cdk/mmt/lib/mmt-stack.ts b/cdk/mmt/lib/mmt-stack.ts index 680a3aa2c..0fafa3cb1 100644 --- a/cdk/mmt/lib/mmt-stack.ts +++ b/cdk/mmt/lib/mmt-stack.ts @@ -14,6 +14,8 @@ export interface MmtStackProps extends cdk.StackProps {} const { STAGE_NAME = 'dev', COLLECTION_TEMPLATES_BUCKET_NAME = `mmt-${STAGE_NAME}-collection-templates`, + STAGING_CONCEPTS_BUCKET_NAME = `mmt-${STAGE_NAME}-staging-concepts`, + STAGING_API_KEY = 'local-staging-api-key', COOKIE_DOMAIN = '.localhost', EDL_CLIENT_ID = '', EDL_PASSWORD = '', @@ -80,6 +82,8 @@ export class MmtStack extends cdk.Stack { const environment = { COLLECTION_TEMPLATES_BUCKET_NAME, + STAGING_CONCEPTS_BUCKET_NAME, + STAGING_API_KEY, COOKIE_DOMAIN, EDL_CLIENT_ID, EDL_PASSWORD, diff --git a/serverless/src/utils/__tests__/getConceptsBucketName.test.js b/serverless/src/utils/__tests__/getConceptsBucketName.test.js index 2946f288b..2333d6493 100644 --- a/serverless/src/utils/__tests__/getConceptsBucketName.test.js +++ b/serverless/src/utils/__tests__/getConceptsBucketName.test.js @@ -5,6 +5,6 @@ describe('getConceptsBucketName', () => { process.env.IS_OFFLINE = true const bucketName = getConceptsBucketName() - expect(bucketName).toBe('mmt-concepts-bucket-local') + expect(bucketName).toBe('mmt-staging-concepts-bucket-local') }) }) diff --git a/serverless/src/utils/getConceptsBucketName.js b/serverless/src/utils/getConceptsBucketName.js index 42e7796a6..6edbceab5 100644 --- a/serverless/src/utils/getConceptsBucketName.js +++ b/serverless/src/utils/getConceptsBucketName.js @@ -3,8 +3,8 @@ */ export const getConceptsBucketName = () => { if (process.env.IS_OFFLINE) { - return 'mmt-concepts-bucket-local' + return 'mmt-staging-concepts-bucket-local' } - return process.env.CONCEPTS_BUCKET_NAME + return process.env.STAGING_CONCEPTS_BUCKET_NAME } diff --git a/setup/startS3.js b/setup/startS3.js index 92bc43be3..64d5de7ef 100644 --- a/setup/startS3.js +++ b/setup/startS3.js @@ -1,8 +1,11 @@ const S3rver = require('s3rver') const { S3Client, CreateBucketCommand, HeadBucketCommand } = require('@aws-sdk/client-s3') -// Allow overriding this value but, this script is only need to be run in dev -const bucketName = process.env.COLLECTION_TEMPLATES_BUCKET_NAME || 'mmt-template-bucket-local' +// Allow overriding these values but, this script is only need to be run in dev +const bucketNames = [ + process.env.COLLECTION_TEMPLATES_BUCKET_NAME || 'mmt-template-bucket-local', + process.env.STAGING_CONCEPTS_BUCKET_NAME || 'mmt-staging-concepts-bucket-local' +] const port = 4569 const startS3 = async () => { @@ -29,18 +32,22 @@ const startS3 = async () => { region: 'us-east-1' }) - try { - await s3Client.send(new HeadBucketCommand({ Bucket: bucketName })) - console.log(`Bucket "${bucketName}" already exists.`) - } catch (error) { - if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { - console.log(`Creating bucket "${bucketName}"...`) - await s3Client.send(new CreateBucketCommand({ Bucket: bucketName })) - console.log(`Bucket "${bucketName}" created.`) - } else { - console.error('Error checking/creating bucket:', error) + const ensureBucketExists = async (bucketName) => { + try { + await s3Client.send(new HeadBucketCommand({ Bucket: bucketName })) + console.log(`Bucket "${bucketName}" already exists.`) + } catch (error) { + if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { + console.log(`Creating bucket "${bucketName}"...`) + await s3Client.send(new CreateBucketCommand({ Bucket: bucketName })) + console.log(`Bucket "${bucketName}" created.`) + } else { + console.error(`Error checking/creating bucket "${bucketName}":`, error) + } } } + + await Promise.all(bucketNames.map(ensureBucketExists)) } startS3().catch((error) => { From bcc89c22593d85d5cac2b6f61e7ccdd09ab452ad Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Wed, 2 Sep 2026 20:15:24 -0400 Subject: [PATCH 06/18] MMT-4199: Secure handling of staging-api-key --- .../__tests__/handler.test.js | 30 +++++++++++++++++-- .../src/createOrUpdateConcept/handler.js | 2 +- .../deleteConcept/__tests__/handler.test.js | 30 +++++++++++++++++-- serverless/src/deleteConcept/handler.js | 2 +- .../src/getConcept/__tests__/handler.test.js | 28 +++++++++++++++-- serverless/src/getConcept/handler.js | 2 +- .../src/getConcepts/__tests__/handler.test.js | 24 ++++++++++++++- serverless/src/getConcepts/handler.js | 2 +- 8 files changed, 106 insertions(+), 14 deletions(-) diff --git a/serverless/src/createOrUpdateConcept/__tests__/handler.test.js b/serverless/src/createOrUpdateConcept/__tests__/handler.test.js index f687fbe2d..1cc500929 100644 --- a/serverless/src/createOrUpdateConcept/__tests__/handler.test.js +++ b/serverless/src/createOrUpdateConcept/__tests__/handler.test.js @@ -48,7 +48,31 @@ describe('createOrUpdateConcept', () => { expect(response.statusCode).toBe(200) }) - describe('when the Prod-Staging-Api-Key header is missing', () => { + describe('when STAGING_API_KEY is not configured in the environment', () => { + test('returns a status code 401 even when no header is sent', async () => { + delete process.env.STAGING_API_KEY + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + // No Staging-Api-Key header sent at all + }, + body: JSON.stringify({ mock: 'Concept Body' }), + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await createOrUpdateConcept(event) + + expect(response.statusCode).toBe(401) + expect(s3ClientMock.commandCalls(PutObjectCommand)).toHaveLength(0) + }) + }) + + describe('when the Staging-Api-Key header is missing', () => { test('returns a status code 401', async () => { const event = { headers: { @@ -68,7 +92,7 @@ describe('createOrUpdateConcept', () => { }) }) - describe('when the Prod-Staging-Api-Key header does not match', () => { + describe('when the Staging-Api-Key header does not match', () => { test('returns a status code 401', async () => { const event = { headers: { @@ -89,7 +113,7 @@ describe('createOrUpdateConcept', () => { }) }) - describe('when the Prod-Staging-Api-Key header has different casing', () => { + describe('when the Staging-Api-Key header has different casing', () => { test('is still accepted (case-insensitive lookup)', async () => { s3ClientMock.on(PutObjectCommand).resolves({ $metadata: { diff --git a/serverless/src/createOrUpdateConcept/handler.js b/serverless/src/createOrUpdateConcept/handler.js index 605c829ac..58ee77df9 100644 --- a/serverless/src/createOrUpdateConcept/handler.js +++ b/serverless/src/createOrUpdateConcept/handler.js @@ -30,7 +30,7 @@ const createOrUpdateConcept = async (event) => { const [, stagingApiKey] = stagingApiKeyHeader || [] - if (stagingApiKey !== process.env.STAGING_API_KEY) { + if (!process.env.STAGING_API_KEY || stagingApiKey !== process.env.STAGING_API_KEY) { console.error('Missing or invalid Staging-Api-Key header') return { diff --git a/serverless/src/deleteConcept/__tests__/handler.test.js b/serverless/src/deleteConcept/__tests__/handler.test.js index bbccccf6c..d1040bfce 100644 --- a/serverless/src/deleteConcept/__tests__/handler.test.js +++ b/serverless/src/deleteConcept/__tests__/handler.test.js @@ -56,7 +56,31 @@ describe('deleteConcept', () => { expect(response.statusCode).toBe(204) }) - describe('when the Prod-Staging-Api-Key header is missing', () => { + describe('when STAGING_API_KEY is not configured in the environment', () => { + test('returns a status code 401 even when no header is sent', async () => { + delete process.env.STAGING_API_KEY + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + // No Staging-Api-Key header sent at all + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await deleteConcept(event) + + expect(response.statusCode).toBe(401) + expect(s3ClientMock.commandCalls(HeadObjectCommand)).toHaveLength(0) + expect(s3ClientMock.commandCalls(DeleteObjectCommand)).toHaveLength(0) + }) + }) + + describe('when the Staging-Api-Key header is missing', () => { test('returns a status code 401', async () => { const event = { headers: { @@ -76,7 +100,7 @@ describe('deleteConcept', () => { }) }) - describe('when the Prod-Staging-Api-Key header does not match', () => { + describe('when the Staging-Api-Key header does not match', () => { test('returns a status code 401', async () => { const event = { headers: { @@ -97,7 +121,7 @@ describe('deleteConcept', () => { }) }) - describe('when the Prod-Staging-Api-Key header has different casing', () => { + describe('when the Staging-Api-Key header has different casing', () => { test('is still accepted (case-insensitive lookup)', async () => { s3ClientMock.on(HeadObjectCommand).resolves({ $metadata: { diff --git a/serverless/src/deleteConcept/handler.js b/serverless/src/deleteConcept/handler.js index 4f60b352f..45899c88f 100644 --- a/serverless/src/deleteConcept/handler.js +++ b/serverless/src/deleteConcept/handler.js @@ -29,7 +29,7 @@ const deleteConcept = async (event) => { const [, stagingApiKey] = stagingApiKeyHeader || [] - if (stagingApiKey !== process.env.STAGING_API_KEY) { + if (!process.env.STAGING_API_KEY || stagingApiKey !== process.env.STAGING_API_KEY) { console.error('Missing or invalid Staging-Api-Key header') return { diff --git a/serverless/src/getConcept/__tests__/handler.test.js b/serverless/src/getConcept/__tests__/handler.test.js index 95f215a9f..a7665eff3 100644 --- a/serverless/src/getConcept/__tests__/handler.test.js +++ b/serverless/src/getConcept/__tests__/handler.test.js @@ -58,7 +58,29 @@ describe('getConcept', () => { }) }) - describe('when the Prod-Staging-Api-Key header is missing', () => { + describe('when STAGING_API_KEY is not configured in the environment', () => { + test('returns a status code 401 even when no header is sent', async () => { + delete process.env.STAGING_API_KEY + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + // No Staging-Api-Key header sent at all + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } + } + + const response = await getConcept(event) + + expect(response.statusCode).toBe(401) + }) + }) + + describe('when the Staging-Api-Key header is missing', () => { test('returns a status code 401', async () => { const event = { headers: { @@ -77,7 +99,7 @@ describe('getConcept', () => { }) }) - describe('when the Prod-Staging-Api-Key header does not match', () => { + describe('when the Staging-Api-Key header does not match', () => { test('returns a status code 401', async () => { const event = { headers: { @@ -97,7 +119,7 @@ describe('getConcept', () => { }) }) - describe('when the Prod-Staging-Api-Key header has different casing', () => { + describe('when the Staging-Api-Key header has different casing', () => { test('is still accepted (case-insensitive lookup)', async () => { const mockConcept = { mock: 'Concept Body' } diff --git a/serverless/src/getConcept/handler.js b/serverless/src/getConcept/handler.js index 97f858c3a..350f3f629 100644 --- a/serverless/src/getConcept/handler.js +++ b/serverless/src/getConcept/handler.js @@ -29,7 +29,7 @@ const getConcept = async (event) => { const [, stagingApiKey] = stagingApiKeyHeader || [] - if (stagingApiKey !== process.env.STAGING_API_KEY) { + if (!process.env.STAGING_API_KEY || stagingApiKey !== process.env.STAGING_API_KEY) { console.error('Missing or invalid Staging-Api-Key header') return { diff --git a/serverless/src/getConcepts/__tests__/handler.test.js b/serverless/src/getConcepts/__tests__/handler.test.js index 7dcd55652..b53fe76b4 100644 --- a/serverless/src/getConcepts/__tests__/handler.test.js +++ b/serverless/src/getConcepts/__tests__/handler.test.js @@ -59,7 +59,29 @@ describe('getConcepts', () => { ]) }) - describe('when the Prod-Staging-Api-Key header is missing', () => { + describe('when STAGING_API_KEY is not configured in the environment', () => { + test('returns a status code 401 even when no header is sent', async () => { + delete process.env.STAGING_API_KEY + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + // No Staging-Api-Key header sent at all + }, + pathParameters: { + conceptType: 'collections', + providerId: 'MMT_1' + } + } + + const response = await getConcepts(event) + + expect(response.statusCode).toBe(401) + expect(s3ListObjects).not.toHaveBeenCalled() + }) + }) + + describe('when the Staging-Api-Key header is missing', () => { test('returns a status code 401', async () => { const event = { headers: { diff --git a/serverless/src/getConcepts/handler.js b/serverless/src/getConcepts/handler.js index adcd4a93d..bb4f6b8c1 100644 --- a/serverless/src/getConcepts/handler.js +++ b/serverless/src/getConcepts/handler.js @@ -28,7 +28,7 @@ const getConcepts = async (event) => { const [, stagingApiKey] = stagingApiKeyHeader || [] - if (stagingApiKey !== process.env.STAGING_API_KEY) { + if (!process.env.STAGING_API_KEY || stagingApiKey !== process.env.STAGING_API_KEY) { console.error('Missing or invalid Staging-Api-Key header') return { From dfb305c5fbf5d11030c3cf7d7419a4240159043d Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Thu, 3 Sep 2026 17:29:35 -0400 Subject: [PATCH 07/18] MMT-4199: Simply delete --- .../deleteConcept/__tests__/handler.test.js | 54 ++++++++----------- serverless/src/deleteConcept/handler.js | 18 +------ 2 files changed, 23 insertions(+), 49 deletions(-) diff --git a/serverless/src/deleteConcept/__tests__/handler.test.js b/serverless/src/deleteConcept/__tests__/handler.test.js index d1040bfce..d3fc459ab 100644 --- a/serverless/src/deleteConcept/__tests__/handler.test.js +++ b/serverless/src/deleteConcept/__tests__/handler.test.js @@ -1,9 +1,5 @@ import { mockClient } from 'aws-sdk-client-mock' -import { - DeleteObjectCommand, - HeadObjectCommand, - S3Client -} from '@aws-sdk/client-s3' +import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3' import deleteConcept from '../handler' @@ -25,12 +21,6 @@ beforeEach(() => { describe('deleteConcept', () => { test('deletes the concept from s3', async () => { - s3ClientMock.on(HeadObjectCommand).resolves({ - $metadata: { - httpStatusCode: 200 - } - }) - s3ClientMock.on(DeleteObjectCommand).resolves({ $metadata: { httpStatusCode: 204, @@ -75,7 +65,6 @@ describe('deleteConcept', () => { const response = await deleteConcept(event) expect(response.statusCode).toBe(401) - expect(s3ClientMock.commandCalls(HeadObjectCommand)).toHaveLength(0) expect(s3ClientMock.commandCalls(DeleteObjectCommand)).toHaveLength(0) }) }) @@ -96,7 +85,7 @@ describe('deleteConcept', () => { const response = await deleteConcept(event) expect(response.statusCode).toBe(401) - expect(s3ClientMock.commandCalls(HeadObjectCommand)).toHaveLength(0) + expect(s3ClientMock.commandCalls(DeleteObjectCommand)).toHaveLength(0) }) }) @@ -117,18 +106,12 @@ describe('deleteConcept', () => { const response = await deleteConcept(event) expect(response.statusCode).toBe(401) - expect(s3ClientMock.commandCalls(HeadObjectCommand)).toHaveLength(0) + expect(s3ClientMock.commandCalls(DeleteObjectCommand)).toHaveLength(0) }) }) describe('when the Staging-Api-Key header has different casing', () => { test('is still accepted (case-insensitive lookup)', async () => { - s3ClientMock.on(HeadObjectCommand).resolves({ - $metadata: { - httpStatusCode: 200 - } - }) - s3ClientMock.on(DeleteObjectCommand).resolves({ $metadata: { httpStatusCode: 204, @@ -172,6 +155,7 @@ describe('deleteConcept', () => { const response = await deleteConcept(event) expect(response.statusCode).toBe(400) + expect(s3ClientMock.commandCalls(DeleteObjectCommand)).toHaveLength(0) }) }) @@ -189,6 +173,7 @@ describe('deleteConcept', () => { const response = await deleteConcept(event) expect(response.statusCode).toBe(401) + expect(s3ClientMock.commandCalls(DeleteObjectCommand)).toHaveLength(0) }) }) @@ -212,34 +197,39 @@ describe('deleteConcept', () => { }) }) - describe('when the concept does not exist in s3', () => { - test('returns a status code 404 and does not attempt to delete', async () => { - s3ClientMock.on(HeadObjectCommand).rejects(new Error('NotFound')) + describe('when deleting a concept that does not exist in s3', () => { + test('is treated as a successful, idempotent delete (no preflight check)', async () => { + // DeleteObject does not error on a missing key - this is the whole + // point of the idempotent-delete approach, so there's no + // HeadObjectCommand mock/check possible anymore + s3ClientMock.on(DeleteObjectCommand).resolves({ + $metadata: { + httpStatusCode: 204, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + attempts: 1, + totalRetryDelay: 0 + } + }) const event = { headers: validStagingHeaders, pathParameters: { conceptType: 'collections', - nativeId: 'TestNativeId', + nativeId: 'NonExistentNativeId', providerId: 'MMT_1' } } const response = await deleteConcept(event) - expect(response.statusCode).toBe(404) - expect(s3ClientMock.commandCalls(DeleteObjectCommand)).toHaveLength(0) + expect(response.statusCode).toBe(204) }) }) describe('when deleting the object in s3 throws an error', () => { test('returns a status code 404', async () => { - s3ClientMock.on(HeadObjectCommand).resolves({ - $metadata: { - httpStatusCode: 200 - } - }) - s3ClientMock.on(DeleteObjectCommand).rejects(new Error('S3 error')) const event = { diff --git a/serverless/src/deleteConcept/handler.js b/serverless/src/deleteConcept/handler.js index 45899c88f..6257523e9 100644 --- a/serverless/src/deleteConcept/handler.js +++ b/serverless/src/deleteConcept/handler.js @@ -1,4 +1,4 @@ -import { DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3' +import { DeleteObjectCommand } from '@aws-sdk/client-s3' import { getApplicationConfig } from '../../../sharedUtils/getConfig' import { getS3Client } from '../utils/getS3Client' @@ -63,22 +63,6 @@ const deleteConcept = async (event) => { const key = `${providerId}/${conceptType}/${nativeId}.json` const conceptsBucketName = getConceptsBucketName() - // DeleteObject is idempotent and won't error on a missing key, so check existence first - try { - await s3Client.send(new HeadObjectCommand({ - Bucket: conceptsBucketName, - Key: key - })) - } catch (headError) { - console.error(`Concept not found for key "${key}"`, headError) - - return { - statusCode: 404, - headers: defaultResponseHeaders - } - } - - // Delete the file from S3 const deleteCommand = new DeleteObjectCommand({ Bucket: conceptsBucketName, Key: key From 8dc6020f6c36d1d6eb6730eb048f89baa119784f Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Thu, 3 Sep 2026 18:01:55 -0400 Subject: [PATCH 08/18] MMT-4199: Add test scripts for local testing --- .../deleteConcept.sh | 181 ++++++++++++++++++ .../localStagingConceptsTesting/getConcept.sh | 129 +++++++++++++ .../getConcepts.sh | 116 +++++++++++ .../localStagingConceptsTesting/local-env.sh | 62 ++++++ .../postConcepts.sh | 132 +++++++++++++ 5 files changed, 620 insertions(+) create mode 100644 scripts/localStagingConceptsTesting/deleteConcept.sh create mode 100644 scripts/localStagingConceptsTesting/getConcept.sh create mode 100644 scripts/localStagingConceptsTesting/getConcepts.sh create mode 100644 scripts/localStagingConceptsTesting/local-env.sh create mode 100644 scripts/localStagingConceptsTesting/postConcepts.sh diff --git a/scripts/localStagingConceptsTesting/deleteConcept.sh b/scripts/localStagingConceptsTesting/deleteConcept.sh new file mode 100644 index 000000000..4f87d2926 --- /dev/null +++ b/scripts/localStagingConceptsTesting/deleteConcept.sh @@ -0,0 +1,181 @@ +#!/bin/bash + +# Tests the deleteConcept endpoint against a locally running MMT API +# (serverless-offline). +# +# Route: +# DELETE {BASE_URL}/dev/providers/:providerId/:conceptType/:nativeId +# +# Sources local-env.sh (if present) for shared local dev config +# (STAGE_NAME, API_BASE_URL, STAGING_API_KEY, etc). Override any of these by +# exporting them yourself before running this script. +# +# This script seeds its own throwaway concept (NATIVE_ID below) via PUT +# before testing delete, so it doesn't consume/remove data seeded by +# postConcepts.sh (e.g. TestCollection1/2/3) that other scripts may rely on. +# +# Usage: +# ./deleteConcept.sh +# PROVIDER_ID=MMT_2 CONCEPT_TYPE=collections ./deleteConcept.sh + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -f "$SCRIPT_DIR/local-env.sh" ]; then + # shellcheck source=local-env.sh + source "$SCRIPT_DIR/local-env.sh" +fi + +BASE_URL="${BASE_URL:-${API_BASE_URL:-http://localhost:4001}}" +STAGE="${STAGE:-${STAGE_NAME:-dev}}" +STAGING_API_KEY="${STAGING_API_KEY:-local-staging-api-key}" +# 'Bearer ABC-1' is a special test-mode token hardcoded in fetchProviders.js +# that grants access to MMT_1 and MMT_2 without needing real EDL/JWT auth. +AUTH_TOKEN="${AUTH_TOKEN:-Bearer ABC-1}" +PROVIDER_ID="${PROVIDER_ID:-MMT_1}" +CONCEPT_TYPE="${CONCEPT_TYPE:-collections}" +NATIVE_ID="${NATIVE_ID:-TestDeleteMe}" + +PASS_COUNT=0 +FAIL_COUNT=0 + +# Seeds the throwaway concept used by the delete tests below. +seed_concept() { + local url="${BASE_URL}/${STAGE}/providers/${PROVIDER_ID}/${CONCEPT_TYPE}/${NATIVE_ID}" + local body + body=$(cat < +# Seeds a single concept using and the JSON body read from +# , e.g.: +# +# ./postConcepts.sh TestCollection1 ./record.json +# +# PROVIDER_ID=MMT_2 CONCEPT_TYPE=collections ./postConcepts.sh TestCollection1 ./record.json + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -f "$SCRIPT_DIR/local-env.sh" ]; then + # shellcheck source=local-env.sh + source "$SCRIPT_DIR/local-env.sh" +fi + +BASE_URL="${BASE_URL:-${API_BASE_URL:-http://localhost:4001}}" +STAGE="${STAGE:-${STAGE_NAME:-dev}}" +STAGING_API_KEY="${STAGING_API_KEY:-local-staging-api-key}" +# 'Bearer ABC-1' is a special test-mode token hardcoded in fetchProviders.js +# that grants access to MMT_1 and MMT_2 without needing real EDL/JWT auth. +AUTH_TOKEN="${AUTH_TOKEN:-Bearer ABC-1}" +PROVIDER_ID="${PROVIDER_ID:-MMT_1}" +CONCEPT_TYPE="${CONCEPT_TYPE:-collections}" + +# nativeId:JSON-body pairs to seed. Add/edit as needed. +# The handler doesn't schema-validate the body, so any valid JSON works here - +# these are just enough to look like plausible concepts. +NATIVE_IDS=( + "TestCollection1" + "TestCollection2" + "TestCollection3" +) + +SUCCESS_COUNT=0 +FAIL_COUNT=0 + +seed_concept() { + local native_id="$1" + local body="$2" + + local url="${BASE_URL}/${STAGE}/providers/${PROVIDER_ID}/${CONCEPT_TYPE}/${native_id}" + + local actual_status + actual_status="$( + curl -s -o /tmp/seed_concept_response_body.json -w '%{http_code}' \ + -X PUT "$url" \ + -H "Staging-Api-Key: $STAGING_API_KEY" \ + -H "Authorization: $AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$body" + )" + + if [ "$actual_status" = "200" ]; then + echo "OK ($actual_status): $native_id" + SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) + else + echo "FAIL ($actual_status): $native_id" + echo " Response body:" + sed 's/^/ /' /tmp/seed_concept_response_body.json + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +echo "== Seeding concepts at ${BASE_URL}/${STAGE}/providers/${PROVIDER_ID}/${CONCEPT_TYPE}/... ==" +echo + +# If a nativeId and a JSON file path are given as positional args, seed just +# that one concept from the file instead of the default sample list. +if [ "$#" -ge 2 ]; then + NATIVE_ID_ARG="$1" + RECORD_FILE_ARG="$2" + + if [ ! -f "$RECORD_FILE_ARG" ]; then + echo "FAIL: file not found: $RECORD_FILE_ARG" + exit 1 + fi + + body="$(cat "$RECORD_FILE_ARG")" + seed_concept "$NATIVE_ID_ARG" "$body" + + echo + echo "== Results: $SUCCESS_COUNT seeded, $FAIL_COUNT failed ==" + + rm -f /tmp/seed_concept_response_body.json + + if [ "$FAIL_COUNT" -ne 0 ]; then + exit 1 + fi + + exit 0 +fi + +for native_id in "${NATIVE_IDS[@]}"; do + body=$(cat < Date: Tue, 8 Sep 2026 17:22:04 -0400 Subject: [PATCH 09/18] MMT-4199: Use api key for auth --- bin/deploy-bamboo.sh | 3 + cdk/mmt/lib/mmt-authorizers.ts | 124 ++++++++++----- cdk/mmt/lib/mmt-functions.ts | 49 +++++- .../lib/mmt-shared-api-gateway-resources.ts | 16 +- cdk/mmt/lib/mmt-stack.ts | 74 ++++++++- .../deleteConcept.sh | 47 +++--- .../localStagingConceptsTesting/getConcept.sh | 48 +++--- .../getConcepts.sh | 47 +++--- .../localStagingConceptsTesting/local-env.sh | 21 ++- .../postConcepts.sh | 15 +- .../stageForProduction.sh | 85 +++++++++++ .../__tests__/handler.test.js | 44 +----- .../src/createOrUpdateConcept/handler.js | 39 ++--- .../deleteConcept/__tests__/handler.test.js | 122 ++------------- serverless/src/deleteConcept/handler.js | 18 +-- .../src/getConcept/__tests__/handler.test.js | 119 ++------------- serverless/src/getConcept/handler.js | 18 +-- .../src/getConcepts/__tests__/handler.test.js | 112 +++----------- serverless/src/getConcepts/handler.js | 18 +-- .../__tests__/handler.test.js | 141 ++++++++++++++++++ .../src/stageConceptForProduction/handler.js | 122 +++++++++++++++ .../__tests__/handler.test.js | 113 ++++++++++++++ .../src/stagingApiKeyAuthorizer/handler.js | 36 +++++ .../utils/__tests__/safeCompareSecret.test.js | 31 ++++ serverless/src/utils/safeCompareSecret.js | 26 ++++ 25 files changed, 927 insertions(+), 561 deletions(-) create mode 100755 scripts/localStagingConceptsTesting/stageForProduction.sh create mode 100644 serverless/src/stageConceptForProduction/__tests__/handler.test.js create mode 100644 serverless/src/stageConceptForProduction/handler.js create mode 100644 serverless/src/stagingApiKeyAuthorizer/__tests__/handler.test.js create mode 100644 serverless/src/stagingApiKeyAuthorizer/handler.js create mode 100644 serverless/src/utils/__tests__/safeCompareSecret.test.js create mode 100644 serverless/src/utils/safeCompareSecret.js diff --git a/bin/deploy-bamboo.sh b/bin/deploy-bamboo.sh index 665fe6f39..f60357698 100755 --- a/bin/deploy-bamboo.sh +++ b/bin/deploy-bamboo.sh @@ -87,6 +87,9 @@ dockerRun() { -e "LOG_DESTINATION_ARN=$bamboo_LOG_DESTINATION_ARN" \ -e "MMT_HOST=$bamboo_MMT_HOST" \ -e "NODE_ENV=production" \ + -e "PRODUCTION_API_HOST=$bamboo_PRODUCTION_API_HOST" \ + -e "PRODUCTION_MMT_HOST=$bamboo_PRODUCTION_MMT_HOST" \ + -e "PRODUCTION_STAGING_API_KEY=$bamboo_PRODUCTION_STAGING_API_KEY" \ -e "NODE_OPTIONS=--max_old_space_size=4096" \ -e "SITE_BUCKET=${bamboo_SITE_BUCKET}" \ -e "STAGE_NAME=$bamboo_STAGE_NAME" \ diff --git a/cdk/mmt/lib/mmt-authorizers.ts b/cdk/mmt/lib/mmt-authorizers.ts index 5023f49ee..3121d7541 100644 --- a/cdk/mmt/lib/mmt-authorizers.ts +++ b/cdk/mmt/lib/mmt-authorizers.ts @@ -8,6 +8,9 @@ import { application } from '@edsc/cdk-utils' export interface MmtAuthorizersProps { apiGatewayRestApi: cdk.aws_apigateway.CfnRestApi; defaultLambdaConfig: application.NodeJsFunctionProps; + // Shared secret for the machine-to-machine concept routes. Injected only into + // `stagingApiKeyAuthorizer`, not the shared Lambda environment. + stagingApiKey: string; } /** @@ -17,52 +20,93 @@ export interface MmtAuthorizersProps { export class MmtAuthorizers extends Construct { public readonly edlAuthorizer: apigateway.CfnAuthorizer + public readonly stagingApiKeyAuthorizer: apigateway.CfnAuthorizer + constructor(scope: cdk.Stack, id: string, props: MmtAuthorizersProps) { super(scope, id) - const { apiGatewayRestApi, defaultLambdaConfig } = props + const { apiGatewayRestApi, defaultLambdaConfig, stagingApiKey } = props const functionNamePrefix = scope.stackName - const edlAuthorizerNestedStack = new cdk.NestedStack(scope, 'EdlAuthorizerNestedStack') - const { lambdaFunction: edlAuthorizerLambda } = new application.NodeJsFunction(edlAuthorizerNestedStack, 'EdlAuthorizerLambda', { - ...defaultLambdaConfig, - entry: '../../serverless/src/edlAuthorizer/handler.js', - functionName: 'edlAuthorizer', - functionNamePrefix - }) + // Creates a REQUEST authorizer backed by a serverless handler, plus the + // API Gateway invoke permission for its Lambda. + const makeRequestAuthorizer = ( + nestedStackId: string, + lambdaId: string, + authorizerId: string, + functionName: string, + entry: string, + identitySource: string, + extraEnvironment: { [key: string]: string } = {} + ) => { + const nestedStack = new cdk.NestedStack(scope, nestedStackId) + + const { lambdaFunction } = new application.NodeJsFunction(nestedStack, lambdaId, { + ...defaultLambdaConfig, + entry, + environment: { + ...defaultLambdaConfig.environment, + ...extraEnvironment + }, + functionName, + functionNamePrefix + }) + + new lambda.CfnPermission(scope, `${lambdaId}PermissionApiGateway`, { + functionName: lambdaFunction.functionName, + action: 'lambda:InvokeFunction', + principal: 'apigateway.amazonaws.com', + sourceArn: [ + 'arn:', + scope.partition, + ':execute-api:', + scope.region, + ':', + scope.account, + ':', + apiGatewayRestApi.ref, + '/*/*' + ].join('') + }) + + return new apigateway.CfnAuthorizer(nestedStack, authorizerId, { + authorizerResultTtlInSeconds: 0, + authorizerUri: cdk.Fn.join('', [ + 'arn:', + cdk.Aws.PARTITION, + ':apigateway:', + cdk.Aws.REGION, + ':lambda:path/2015-03-31/functions/', + lambdaFunction.functionArn, + '/invocations' + ]), + identitySource, + name: functionName, + restApiId: apiGatewayRestApi.ref, + type: 'REQUEST' + }) + } - new lambda.CfnPermission(scope, 'EdlAuthorizerLambdaPermissionApiGateway', { - functionName: edlAuthorizerLambda.functionName, - action: 'lambda:InvokeFunction', - principal: 'apigateway.amazonaws.com', - sourceArn: [ - 'arn:', - scope.partition, - ':execute-api:', - scope.region, - ':', - scope.account, - ':', - apiGatewayRestApi.ref, - '/*/*' - ].join('') - }) + this.edlAuthorizer = makeRequestAuthorizer( + 'EdlAuthorizerNestedStack', + 'EdlAuthorizerLambda', + 'EdlAuthorizer', + 'edlAuthorizer', + '../../serverless/src/edlAuthorizer/handler.js', + 'method.request.header.Authorization' + ) - this.edlAuthorizer = new apigateway.CfnAuthorizer(edlAuthorizerNestedStack, 'EdlAuthorizer', { - authorizerResultTtlInSeconds: 0, - authorizerUri: cdk.Fn.join('', [ - 'arn:', - cdk.Aws.PARTITION, - ':apigateway:', - cdk.Aws.REGION, - ':lambda:path/2015-03-31/functions/', - edlAuthorizerLambda.functionArn, - '/invocations' - ]), - identitySource: 'method.request.header.Authorization', - name: 'edlAuthorizer', - restApiId: apiGatewayRestApi.ref, - type: 'REQUEST' - }) + // API-key authorizer for the machine-to-machine "staging concepts" routes. + // The caller (the MMT UAT forwarding Lambda) authenticates with a shared + // secret in the Staging-Api-Key header. + this.stagingApiKeyAuthorizer = makeRequestAuthorizer( + 'StagingApiKeyAuthorizerNestedStack', + 'StagingApiKeyAuthorizerLambda', + 'StagingApiKeyAuthorizer', + 'stagingApiKeyAuthorizer', + '../../serverless/src/stagingApiKeyAuthorizer/handler.js', + 'method.request.header.Staging-Api-Key', + { STAGING_API_KEY: stagingApiKey } + ) } } diff --git a/cdk/mmt/lib/mmt-functions.ts b/cdk/mmt/lib/mmt-functions.ts index 819ff01fd..fbe4da4a3 100644 --- a/cdk/mmt/lib/mmt-functions.ts +++ b/cdk/mmt/lib/mmt-functions.ts @@ -12,6 +12,7 @@ export interface MmtFunctionsProps { apiGatewayRestApi: cdk.aws_apigateway.CfnRestApi; authorizers: { edlAuthorizer: apigateway.CfnAuthorizer; + stagingApiKeyAuthorizer: apigateway.CfnAuthorizer; }; // MMT keeps explicit CORS config so API Gateway OPTIONS responses can control: // - allowOrigin: which browser origin can call the API @@ -26,7 +27,17 @@ export interface MmtFunctionsProps { allowHeaders: string[]; }; defaultLambdaConfig: application.NodeJsFunctionProps; + // UAT-only config for the `stageConceptForProduction` forwarding Lambda. + // Injected only into that handler, not the shared Lambda environment. + productionForwardingConfig: { + PRODUCTION_API_HOST: string; + PRODUCTION_MMT_HOST: string; + PRODUCTION_STAGING_API_KEY: string; + }; s3LambdaRole: iam.IRole; + // Shared secret re-checked in `createOrUpdateConcept`. Injected only into that + // handler, not the shared Lambda environment. + stagingApiKey: string; } /** @@ -43,7 +54,9 @@ export class MmtFunctions extends Construct { authorizers, corsConfig, defaultLambdaConfig, - s3LambdaRole + productionForwardingConfig, + s3LambdaRole, + stagingApiKey } = props const functionNamePrefix = scope.stackName @@ -288,18 +301,27 @@ export class MmtFunctions extends Construct { }) // createOrUpdateConcept - PUT /providers/{providerId}/{conceptType}/{nativeId} + // The only machine-to-machine concept route: it is called cross-environment + // by the UAT `stageConceptForProduction` Lambda using the shared staging API + // key, so it sits behind `stagingApiKeyAuthorizer` rather than the EDL + // authorizer. The read/list/delete routes above stay EDL-authenticated + // (real browser users) and keep their per-user `fetchProviders` check. new application.NodeJsFunction(new cdk.NestedStack(scope, 'CreateOrUpdateConceptNestedStack'), 'CreateOrUpdateConceptLambda', { ...defaultLambdaConfig, api: { apiGatewayDeployment, apiGatewayResource: resources.providersConceptTypeNativeIdResource, apiGatewayRestApi, - authorizer: authorizers.edlAuthorizer, + authorizer: authorizers.stagingApiKeyAuthorizer, methods: ['PUT'], parentPath: 'providersProviderIdVarConceptTypeVar', path: '{nativeId}' }, entry: '../../serverless/src/createOrUpdateConcept/handler.js', + environment: { + ...defaultLambdaConfig.environment, + STAGING_API_KEY: stagingApiKey + }, functionName: 'createOrUpdateConcept', functionNamePrefix, role: s3LambdaRole @@ -322,5 +344,28 @@ export class MmtFunctions extends Construct { functionNamePrefix, role: s3LambdaRole }) + + // stageConceptForProduction - POST /providers/{providerId}/{conceptType}/{nativeId}/stage-for-production + // UAT-only: forwards collection metadata to the Production API Gateway using + // the Production staging API key held in an environment variable. + new application.NodeJsFunction(new cdk.NestedStack(scope, 'StageConceptForProductionNestedStack'), 'StageConceptForProductionLambda', { + ...defaultLambdaConfig, + api: { + apiGatewayDeployment, + apiGatewayResource: resources.providersConceptTypeNativeIdStageForProductionResource, + apiGatewayRestApi, + authorizer: authorizers.edlAuthorizer, + methods: ['POST'], + parentPath: 'providersProviderIdVarConceptTypeVarNativeIdVar', + path: 'stage-for-production' + }, + entry: '../../serverless/src/stageConceptForProduction/handler.js', + environment: { + ...defaultLambdaConfig.environment, + ...productionForwardingConfig + }, + functionName: 'stageConceptForProduction', + functionNamePrefix + }) } } diff --git a/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts b/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts index 14ec1c4fb..971659e6b 100644 --- a/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts +++ b/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts @@ -27,6 +27,7 @@ export class MmtApiResources extends Construct { public readonly gkrSendFeedbackResource: apigateway.CfnResource public readonly providersConceptTypeResource: apigateway.CfnResource public readonly providersConceptTypeNativeIdResource: apigateway.CfnResource + public readonly providersConceptTypeNativeIdStageForProductionResource: apigateway.CfnResource public readonly providersTemplatesResource: apigateway.CfnResource public readonly providersTemplatesIdResource: apigateway.CfnResource public readonly templatesResource: apigateway.CfnResource @@ -134,6 +135,13 @@ export class MmtApiResources extends Construct { }) this.providersConceptTypeNativeIdResource = providersConceptTypeNativeIdResource + const providersConceptTypeNativeIdStageForProductionResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceProvidersProviderIdVarConceptTypeVarNativeIdVarStageForProduction', { + parentId: providersConceptTypeNativeIdResource.ref, + pathPart: 'stage-for-production', + restApiId: apiGatewayRestApi.ref + }) + this.providersConceptTypeNativeIdStageForProductionResource = providersConceptTypeNativeIdStageForProductionResource + const templatesResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceTemplates', { parentId: apiGatewayRestApi.attrRootResourceId, pathPart: 'templates', @@ -159,6 +167,12 @@ export class MmtApiResources extends Construct { addOptions('ProvidersProviderIdVarConceptTypeVar', providersConceptTypeResource, ['GET']) - addOptions('ProvidersProviderIdVarConceptTypeVarNativeIdVar', providersConceptTypeNativeIdResource, ['GET', 'PUT', 'DELETE']) + // PUT (createOrUpdateConcept) is deliberately omitted: it is a + // machine-to-machine route behind `stagingApiKeyAuthorizer`, called only by + // the UAT forwarding Lambda (server-to-server, no CORS preflight). Leaving + // PUT out of the CORS allow-list makes a browser preflight for it fail. + addOptions('ProvidersProviderIdVarConceptTypeVarNativeIdVar', providersConceptTypeNativeIdResource, ['GET', 'DELETE']) + + addOptions('ProvidersProviderIdVarConceptTypeVarNativeIdVarStageForProduction', providersConceptTypeNativeIdStageForProductionResource, ['POST']) } } diff --git a/cdk/mmt/lib/mmt-stack.ts b/cdk/mmt/lib/mmt-stack.ts index 0fafa3cb1..25aa9adca 100644 --- a/cdk/mmt/lib/mmt-stack.ts +++ b/cdk/mmt/lib/mmt-stack.ts @@ -3,6 +3,7 @@ import * as cdk from 'aws-cdk-lib' import * as ec2 from 'aws-cdk-lib/aws-ec2' import * as iam from 'aws-cdk-lib/aws-iam' import * as lambda from 'aws-cdk-lib/aws-lambda' +import * as s3 from 'aws-cdk-lib/aws-s3' import { application } from '@edsc/cdk-utils' @@ -16,6 +17,11 @@ const { COLLECTION_TEMPLATES_BUCKET_NAME = `mmt-${STAGE_NAME}-collection-templates`, STAGING_CONCEPTS_BUCKET_NAME = `mmt-${STAGE_NAME}-staging-concepts`, STAGING_API_KEY = 'local-staging-api-key', + // Cross-environment "Stage for Production" promotion. Only set for the UAT + // deployment; empty elsewhere (the forwarding Lambda returns 500 if invoked). + PRODUCTION_API_HOST = '', + PRODUCTION_MMT_HOST = '', + PRODUCTION_STAGING_API_KEY = 'local-staging-api-key', COOKIE_DOMAIN = '.localhost', EDL_CLIENT_ID = '', EDL_PASSWORD = '', @@ -35,6 +41,31 @@ const { const runtime = lambda.Runtime.NODEJS_20_X const INFRA_EXPORT_PREFIX = 'cdk' +// Well-known placeholder shared by local dev (see scripts/localStagingConceptsTesting/local-env.sh). +// It is committed to the repo, so it must never reach a deployed environment. +const LOCAL_STAGING_API_KEY_PLACEHOLDER = 'local-staging-api-key' + +// bin/deploy-bamboo.sh sets NODE_ENV=production for every deployed stage; local +// `run-synth` (prestart:api) does not, so this only fails real deployments. +const isDeployedEnvironment = NODE_ENV === 'production' + +const isMissingOrPlaceholder = (value: string) => !value || value === LOCAL_STAGING_API_KEY_PLACEHOLDER + +if (isDeployedEnvironment) { + // The staging API key is the only credential in front of the + // machine-to-machine createOrUpdateConcept route. Fail the synth rather than + // ship the source-controlled placeholder if the Bamboo variable is missing. + if (isMissingOrPlaceholder(STAGING_API_KEY)) { + throw new Error('STAGING_API_KEY must be set to a non-placeholder value for deployed environments') + } + + // PRODUCTION_STAGING_API_KEY is only used by the UAT "stage for production" + // forwarding Lambda, i.e. when PRODUCTION_API_HOST is configured. + if (PRODUCTION_API_HOST && isMissingOrPlaceholder(PRODUCTION_STAGING_API_KEY)) { + throw new Error('PRODUCTION_STAGING_API_KEY must be set to a non-placeholder value when PRODUCTION_API_HOST is configured') + } +} + const allowHeaders = [ 'Access-Control-Allow-Origin', 'Access-Control-Allow-Credentials', @@ -42,6 +73,7 @@ const allowHeaders = [ 'Access-Control-Request-Methods', 'Authorization', 'Origin', + 'Staging-Api-Key', 'User-Agent' ] @@ -80,10 +112,13 @@ export class MmtStack extends cdk.Stack { const { apiGatewayDeployment, apiGatewayRestApi } = apiGateway + // Shared environment for every Lambda. The staging API keys are deliberately + // NOT here - they are the credentials guarding the machine-to-machine + // concept routes, so they are passed only to the handlers that need them + // (see `stagingApiKey` and `productionForwardingConfig` below). const environment = { COLLECTION_TEMPLATES_BUCKET_NAME, STAGING_CONCEPTS_BUCKET_NAME, - STAGING_API_KEY, COOKIE_DOMAIN, EDL_CLIENT_ID, EDL_PASSWORD, @@ -93,6 +128,17 @@ export class MmtStack extends cdk.Stack { NODE_OPTIONS: '--enable-source-maps' } + // Secret used by `stagingApiKeyAuthorizer` and re-checked in + // `createOrUpdateConcept`. + const stagingApiKey = STAGING_API_KEY + + // UAT-only config for the `stageConceptForProduction` forwarding Lambda. + const productionForwardingConfig = { + PRODUCTION_API_HOST, + PRODUCTION_MMT_HOST, + PRODUCTION_STAGING_API_KEY + } + const defaultLambdaConfig: application.NodeJsFunctionProps = { bundling: { // Bundle runtime dependencies into the Lambda artifact. @@ -140,9 +186,26 @@ export class MmtStack extends cdk.Stack { resources: ['*'] })) + // Staging concepts bucket. Objects are transient promotion artifacts, so + // they expire 30 days after creation. RETAIN keeps staged data if the stack + // is ever destroyed. + // eslint-disable-next-line no-new + new s3.Bucket(this, 'StagingConceptsBucket', { + bucketName: STAGING_CONCEPTS_BUCKET_NAME, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + removalPolicy: cdk.RemovalPolicy.RETAIN, + lifecycleRules: [{ + id: 'expire-staged-concepts', + enabled: true, + expiration: cdk.Duration.days(30) + }] + }) + const authorizers = new MmtAuthorizers(this, 'Authorizers', { apiGatewayRestApi, - defaultLambdaConfig + defaultLambdaConfig, + stagingApiKey }) // eslint-disable-next-line no-new @@ -150,7 +213,8 @@ export class MmtStack extends cdk.Stack { apiGatewayDeployment, apiGatewayRestApi, authorizers: { - edlAuthorizer: authorizers.edlAuthorizer + edlAuthorizer: authorizers.edlAuthorizer, + stagingApiKeyAuthorizer: authorizers.stagingApiKeyAuthorizer }, corsConfig: { allowCredentials: true, @@ -158,7 +222,9 @@ export class MmtStack extends cdk.Stack { allowOrigin: MMT_HOST }, defaultLambdaConfig, - s3LambdaRole: iamRoleCustomResourcesLambdaExecution + productionForwardingConfig, + s3LambdaRole: iamRoleCustomResourcesLambdaExecution, + stagingApiKey }) this.serviceEndpoint = [ diff --git a/scripts/localStagingConceptsTesting/deleteConcept.sh b/scripts/localStagingConceptsTesting/deleteConcept.sh index 4f87d2926..5595d2ede 100644 --- a/scripts/localStagingConceptsTesting/deleteConcept.sh +++ b/scripts/localStagingConceptsTesting/deleteConcept.sh @@ -6,6 +6,11 @@ # Route: # DELETE {BASE_URL}/dev/providers/:providerId/:conceptType/:nativeId # +# This route is EDL-authenticated (real browser user) and runs a per-user +# `fetchProviders` provider-permission check in the handler. It does NOT use +# the Staging-Api-Key. The seed step below uses PUT createOrUpdateConcept, +# which IS the machine-to-machine route and still needs the Staging-Api-Key. +# # Sources local-env.sh (if present) for shared local dev config # (STAGE_NAME, API_BASE_URL, STAGING_API_KEY, etc). Override any of these by # exporting them yourself before running this script. @@ -40,7 +45,8 @@ NATIVE_ID="${NATIVE_ID:-TestDeleteMe}" PASS_COUNT=0 FAIL_COUNT=0 -# Seeds the throwaway concept used by the delete tests below. +# Seeds the throwaway concept used by the delete tests below via the +# machine-to-machine PUT route (Staging-Api-Key required). seed_concept() { local url="${BASE_URL}/${STAGE}/providers/${PROVIDER_ID}/${CONCEPT_TYPE}/${NATIVE_ID}" local body @@ -59,7 +65,6 @@ EOF curl -s -o /tmp/delete_concept_seed_body.json -w '%{http_code}' \ -X PUT "$url" \ -H "Staging-Api-Key: $STAGING_API_KEY" \ - -H "Authorization: $AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d "$body" )" @@ -76,29 +81,22 @@ EOF } # Runs a curl DELETE request and asserts the response status code. -# Args: description, expected_status, provider_id, concept_type, native_id, [staging_key_override], [auth_header_override] +# Args: description, expected_status, provider_id, concept_type, native_id, [auth_header_override] # -# staging_key/auth_header default to the env vars (STAGING_API_KEY/AUTH_TOKEN, -# normally set via local-env.sh) when the arg is omitted entirely. -# Pass "" explicitly to omit the header (e.g. to test a missing-header case), -# or pass a specific string to test a wrong/overridden value. +# auth_header defaults to $AUTH_TOKEN when the arg is omitted entirely. +# Pass "" explicitly to omit the Authorization header (missing-auth case). run_test() { local description="$1" local expected_status="$2" local provider_id="$3" local concept_type="$4" local native_id="$5" - local staging_key="${6-$STAGING_API_KEY}" - local auth_header="${7-$AUTH_TOKEN}" + local auth_header="${6-$AUTH_TOKEN}" local url="${BASE_URL}/${STAGE}/providers/${provider_id}/${concept_type}/${native_id}" local curl_args=(-s -o /tmp/delete_concept_response_body.json -w '%{http_code}' -X DELETE "$url") - if [ -n "$staging_key" ]; then - curl_args+=(-H "Staging-Api-Key: $staging_key") - fi - if [ -n "$auth_header" ]; then curl_args+=(-H "Authorization: $auth_header") fi @@ -127,30 +125,25 @@ echo # Negative-path tests first: these must NOT actually delete the concept, so # the final successful-delete test below still has something to delete. +# fetchProviders throws when no token is present, and the handler maps that to 404. run_test \ - "missing Staging-Api-Key header" \ - "401" \ + "missing Authorization header" \ + "404" \ "$PROVIDER_ID" "$CONCEPT_TYPE" "$NATIVE_ID" \ "" +# 'Bearer ABC-1' only grants MMT_1 / MMT_2, so any other provider fails the +# per-user provider-permission check with 401. run_test \ - "wrong Staging-Api-Key header" \ + "unauthorized provider (outside test-mode allowlist MMT_1/MMT_2)" \ "401" \ - "$PROVIDER_ID" "$CONCEPT_TYPE" "$NATIVE_ID" \ - "wrong-key" + "MMT_UNAUTHORIZED" "$CONCEPT_TYPE" "$NATIVE_ID" run_test \ "invalid conceptType" \ "400" \ "$PROVIDER_ID" "invalid-type" "$NATIVE_ID" -# The 'Bearer ABC-1' test-mode auth check runs before any provider-existence -# check, so a provider outside the allowlist fails auth (401), not 404. -run_test \ - "unauthorized provider (outside test-mode allowlist MMT_1/MMT_2)" \ - "401" \ - "MMT_UNAUTHORIZED" "$CONCEPT_TYPE" "$NATIVE_ID" - # S3's DeleteObject doesn't error on a missing key, so deleteConcept is # idempotent - deleting a nativeId that was never seeded (or was already # deleted) still returns 204, same as a real delete. @@ -162,7 +155,7 @@ run_test \ # Successful delete, then confirm re-deleting is still a 204 (idempotent). run_test \ - "successful delete with valid headers" \ + "successful delete with valid EDL token" \ "204" \ "$PROVIDER_ID" "$CONCEPT_TYPE" "$NATIVE_ID" @@ -178,4 +171,4 @@ rm -f /tmp/delete_concept_response_body.json if [ "$FAIL_COUNT" -ne 0 ]; then exit 1 -fi \ No newline at end of file +fi diff --git a/scripts/localStagingConceptsTesting/getConcept.sh b/scripts/localStagingConceptsTesting/getConcept.sh index 9f963270b..1b36a3afb 100644 --- a/scripts/localStagingConceptsTesting/getConcept.sh +++ b/scripts/localStagingConceptsTesting/getConcept.sh @@ -6,9 +6,13 @@ # Route: # GET {BASE_URL}/dev/providers/:providerId/:conceptType/:nativeId # +# This route is EDL-authenticated (real browser user) and runs a per-user +# `fetchProviders` provider-permission check in the handler. It does NOT use +# the Staging-Api-Key - only createOrUpdateConcept (PUT) does. +# # Sources local-env.sh (if present) for shared local dev config -# (STAGE_NAME, API_BASE_URL, STAGING_API_KEY, etc). Override any of these by -# exporting them yourself before running this script. +# (STAGE_NAME, API_BASE_URL, etc). Override any of these by exporting them +# yourself before running this script. # # Assumes TestCollection1 has already been seeded, e.g. via: # ./postConcepts.sh @@ -28,7 +32,6 @@ fi BASE_URL="${BASE_URL:-${API_BASE_URL:-http://localhost:4001}}" STAGE="${STAGE:-${STAGE_NAME:-dev}}" -STAGING_API_KEY="${STAGING_API_KEY:-local-staging-api-key}" # 'Bearer ABC-1' is a special test-mode token hardcoded in fetchProviders.js # that grants access to MMT_1 and MMT_2 without needing real EDL/JWT auth. AUTH_TOKEN="${AUTH_TOKEN:-Bearer ABC-1}" @@ -40,29 +43,22 @@ PASS_COUNT=0 FAIL_COUNT=0 # Runs a curl request and asserts the response status code. -# Args: description, expected_status, provider_id, concept_type, native_id, [staging_key_override], [auth_header_override] +# Args: description, expected_status, provider_id, concept_type, native_id, [auth_header_override] # -# staging_key/auth_header default to the env vars (STAGING_API_KEY/AUTH_TOKEN, -# normally set via local-env.sh) when the arg is omitted entirely. -# Pass "" explicitly to omit the header (e.g. to test a missing-header case), -# or pass a specific string to test a wrong/overridden value. +# auth_header defaults to $AUTH_TOKEN when the arg is omitted entirely. +# Pass "" explicitly to omit the Authorization header (missing-auth case). run_test() { local description="$1" local expected_status="$2" local provider_id="$3" local concept_type="$4" local native_id="$5" - local staging_key="${6-$STAGING_API_KEY}" - local auth_header="${7-$AUTH_TOKEN}" + local auth_header="${6-$AUTH_TOKEN}" local url="${BASE_URL}/${STAGE}/providers/${provider_id}/${concept_type}/${native_id}" local curl_args=(-s -o /tmp/get_concept_response_body.json -w '%{http_code}' -X GET "$url") - if [ -n "$staging_key" ]; then - curl_args+=(-H "Staging-Api-Key: $staging_key") - fi - if [ -n "$auth_header" ]; then curl_args+=(-H "Authorization: $auth_header") fi @@ -85,21 +81,23 @@ echo "== Testing getConcept endpoint at ${BASE_URL}/${STAGE}/providers/... ==" echo run_test \ - "successful get with valid headers" \ + "successful get with valid EDL token" \ "200" \ "$PROVIDER_ID" "$CONCEPT_TYPE" "$NATIVE_ID" +# fetchProviders throws when no token is present, and the handler maps that to 404. run_test \ - "missing Staging-Api-Key header" \ - "401" \ + "missing Authorization header" \ + "404" \ "$PROVIDER_ID" "$CONCEPT_TYPE" "$NATIVE_ID" \ "" +# 'Bearer ABC-1' only grants MMT_1 / MMT_2, so any other provider fails the +# per-user provider-permission check with 401. run_test \ - "wrong Staging-Api-Key header" \ + "unauthorized provider (outside test-mode allowlist MMT_1/MMT_2)" \ "401" \ - "$PROVIDER_ID" "$CONCEPT_TYPE" "$NATIVE_ID" \ - "wrong-key" + "MMT_UNAUTHORIZED" "$CONCEPT_TYPE" "$NATIVE_ID" run_test \ "invalid conceptType" \ @@ -111,14 +109,6 @@ run_test \ "404" \ "$PROVIDER_ID" "$CONCEPT_TYPE" "NativeIdThatDoesNotExist" -# The 'Bearer ABC-1' test-mode auth check runs before any provider-existence -# check (confirmed via deleteConcept.sh testing), so a provider outside the -# allowlist fails auth (401), not 404. -run_test \ - "unauthorized provider (outside test-mode allowlist MMT_1/MMT_2)" \ - "401" \ - "MMT_UNAUTHORIZED" "$CONCEPT_TYPE" "$NATIVE_ID" - echo echo "== Results: $PASS_COUNT passed, $FAIL_COUNT failed ==" @@ -126,4 +116,4 @@ rm -f /tmp/get_concept_response_body.json if [ "$FAIL_COUNT" -ne 0 ]; then exit 1 -fi \ No newline at end of file +fi diff --git a/scripts/localStagingConceptsTesting/getConcepts.sh b/scripts/localStagingConceptsTesting/getConcepts.sh index 5627e0cda..cb5a11273 100644 --- a/scripts/localStagingConceptsTesting/getConcepts.sh +++ b/scripts/localStagingConceptsTesting/getConcepts.sh @@ -6,9 +6,13 @@ # Route (confirmed from local server startup log): # GET {BASE_URL}/dev/providers/:providerId/:conceptType # +# This route is EDL-authenticated (real browser user) and runs a per-user +# `fetchProviders` provider-permission check in the handler. It does NOT use +# the Staging-Api-Key - only createOrUpdateConcept (PUT) does. +# # Sources local-env.sh (if present) for shared local dev config -# (STAGE_NAME, API_BASE_URL, STAGING_API_KEY, etc). Override any of these by -# exporting them yourself before running this script. +# (STAGE_NAME, API_BASE_URL, etc). Override any of these by exporting them +# yourself before running this script. # # Usage: # ./getConcepts.sh @@ -25,7 +29,6 @@ fi BASE_URL="${BASE_URL:-${API_BASE_URL:-http://localhost:4001}}" STAGE="${STAGE:-${STAGE_NAME:-dev}}" -STAGING_API_KEY="${STAGING_API_KEY:-local-staging-api-key}" # 'Bearer ABC-1' is a special test-mode token hardcoded in fetchProviders.js # that grants access to MMT_1 and MMT_2 without needing real EDL/JWT auth. AUTH_TOKEN="${AUTH_TOKEN:-Bearer ABC-1}" @@ -36,28 +39,21 @@ PASS_COUNT=0 FAIL_COUNT=0 # Runs a curl request and asserts the response status code. -# Args: description, expected_status, provider_id, concept_type, [staging_key_override], [auth_header_override] +# Args: description, expected_status, provider_id, concept_type, [auth_header_override] # -# staging_key/auth_header default to the env vars (STAGING_API_KEY/AUTH_TOKEN, -# normally set via local-env.sh) when the arg is omitted entirely. -# Pass "" explicitly to omit the header (e.g. to test a missing-header case), -# or pass a specific string to test a wrong/overridden value. +# auth_header defaults to $AUTH_TOKEN when the arg is omitted entirely. +# Pass "" explicitly to omit the Authorization header (missing-auth case). run_test() { local description="$1" local expected_status="$2" local provider_id="$3" local concept_type="$4" - local staging_key="${5-$STAGING_API_KEY}" - local auth_header="${6-$AUTH_TOKEN}" + local auth_header="${5-$AUTH_TOKEN}" local url="${BASE_URL}/${STAGE}/providers/${provider_id}/${concept_type}" local curl_args=(-s -o /tmp/get_concepts_response_body.json -w '%{http_code}' -X GET "$url") - if [ -n "$staging_key" ]; then - curl_args+=(-H "Staging-Api-Key: $staging_key") - fi - if [ -n "$auth_header" ]; then curl_args+=(-H "Authorization: $auth_header") fi @@ -80,32 +76,29 @@ echo "== Testing getConcepts endpoint at ${BASE_URL}/${STAGE}/providers/... ==" echo run_test \ - "successful list with valid headers" \ + "successful list with valid EDL token" \ "200" \ "$PROVIDER_ID" "$CONCEPT_TYPE" +# fetchProviders throws when no token is present, and the handler maps that to 404. run_test \ - "missing Staging-Api-Key header" \ - "401" \ + "missing Authorization header" \ + "404" \ "$PROVIDER_ID" "$CONCEPT_TYPE" \ "" +# 'Bearer ABC-1' only grants MMT_1 / MMT_2. getConcepts returns 404 (not 401) +# for a provider the user cannot act for. run_test \ - "wrong Staging-Api-Key header" \ - "401" \ - "$PROVIDER_ID" "$CONCEPT_TYPE" \ - "wrong-key" + "unauthorized provider (outside test-mode allowlist MMT_1/MMT_2)" \ + "404" \ + "MMT_UNAUTHORIZED" "$CONCEPT_TYPE" run_test \ "invalid conceptType" \ "400" \ "$PROVIDER_ID" "invalid-type" -run_test \ - "unauthorized provider (outside test-mode allowlist MMT_1/MMT_2)" \ - "404" \ - "MMT_UNAUTHORIZED" "$CONCEPT_TYPE" - echo echo "== Results: $PASS_COUNT passed, $FAIL_COUNT failed ==" @@ -113,4 +106,4 @@ rm -f /tmp/get_concepts_response_body.json if [ "$FAIL_COUNT" -ne 0 ]; then exit 1 -fi \ No newline at end of file +fi diff --git a/scripts/localStagingConceptsTesting/local-env.sh b/scripts/localStagingConceptsTesting/local-env.sh index 06e20f3c1..6f92f9537 100644 --- a/scripts/localStagingConceptsTesting/local-env.sh +++ b/scripts/localStagingConceptsTesting/local-env.sh @@ -19,19 +19,29 @@ export NODE_ENV="${NODE_ENV:-development}" # S3 buckets (local S3 server via s3rver, see startS3.js) export COLLECTION_TEMPLATES_BUCKET_NAME="${COLLECTION_TEMPLATES_BUCKET_NAME:-mmt-template-bucket-local}" -export CONCEPTS_BUCKET_NAME="${CONCEPTS_BUCKET_NAME:-mmt-concepts-bucket-local}" -# mmt-stack.ts currently reads this name for the concepts bucket - keep both -# in sync until/unless the stack is updated to use CONCEPTS_BUCKET_NAME +# Offline, getConceptsBucketName() hardcodes 'mmt-staging-concepts-bucket-local' +# regardless of env, and startS3.js creates STAGING_CONCEPTS_BUCKET_NAME (falling +# back to the same literal). Keep every name below equal to that literal so the +# bucket s3rver creates is exactly the one the handlers read/write locally. +export CONCEPTS_BUCKET_NAME="${CONCEPTS_BUCKET_NAME:-mmt-staging-concepts-bucket-local}" export STAGING_CONCEPTS_BUCKET_NAME="${STAGING_CONCEPTS_BUCKET_NAME:-$CONCEPTS_BUCKET_NAME}" # Local API Gateway endpoint (serverless-offline), used by test/seed scripts export API_BASE_URL="${API_BASE_URL:-http://localhost:4001}" # Auth -# 'local-staging-api-key' must match the Staging-Api-Key header sent by -# test/seed scripts (test-get-concepts-endpoint.sh, seed-concepts.sh, etc.) +# 'local-staging-api-key' must match the Staging-Api-Key header sent by the +# machine-to-machine seed script (postConcepts.sh) and used server-side by the +# forwarding Lambda in stageForProduction.sh. export STAGING_API_KEY="${STAGING_API_KEY:-local-staging-api-key}" export JWT_SECRET="${JWT_SECRET:-local-secret}" + +# Cross-environment "Stage for Production" promotion. Locally these loop the +# forwarding Lambda back to the same local API so the round trip can be tested +# end to end (the record lands in the local S3 concepts bucket). +export PRODUCTION_API_HOST="${PRODUCTION_API_HOST:-http://localhost:4001/dev}" +export PRODUCTION_MMT_HOST="${PRODUCTION_MMT_HOST:-http://localhost:5173}" +export PRODUCTION_STAGING_API_KEY="${PRODUCTION_STAGING_API_KEY:-local-staging-api-key}" export JWT_VALID_TIME="${JWT_VALID_TIME:-900}" export EDL_CLIENT_ID="${EDL_CLIENT_ID:-}" export EDL_PASSWORD="${EDL_PASSWORD:-}" @@ -59,4 +69,5 @@ echo " CONCEPTS_BUCKET_NAME=$CONCEPTS_BUCKET_NAME" echo " STAGING_CONCEPTS_BUCKET_NAME=$STAGING_CONCEPTS_BUCKET_NAME" echo " API_BASE_URL=$API_BASE_URL" echo " STAGING_API_KEY=$STAGING_API_KEY" +echo " PRODUCTION_API_HOST=$PRODUCTION_API_HOST" echo " MMT_HOST=$MMT_HOST" \ No newline at end of file diff --git a/scripts/localStagingConceptsTesting/postConcepts.sh b/scripts/localStagingConceptsTesting/postConcepts.sh index 1d72e9914..cfd6918bb 100644 --- a/scripts/localStagingConceptsTesting/postConcepts.sh +++ b/scripts/localStagingConceptsTesting/postConcepts.sh @@ -1,12 +1,21 @@ #!/bin/bash -# Seeds sample concepts into local S3 via the createOrUpdateConcept endpoint, -# so there's data available to list/retrieve/delete when testing locally -# (e.g. with test-get-concepts-endpoint.sh). +# Seeds sample concepts into local S3 by calling the createOrUpdateConcept +# endpoint directly, so there's data available for getConcepts.sh, getConcept.sh +# and deleteConcept.sh to list/retrieve/delete. getConcepts.sh in particular +# needs several records to verify sort order; deleteConcept.sh seeds its own +# throwaway record and does not depend on this script. # # Route (confirmed from local server startup log): # PUT {BASE_URL}/dev/providers/:providerId/:conceptType/:nativeId # +# This is a direct exercise of the machine-to-machine PUT route, authenticated +# with the Staging-Api-Key header. It is NOT stageForProduction.sh: that script +# POSTs to /stage-for-production, which runs the UAT forwarding Lambda (EDL auth +# + a server-side call back to this same PUT route). Both end up writing to local +# S3, but use this one when you just want fixture data without involving the +# forwarding Lambda or the PRODUCTION_* config. +# # Sources local-env.sh (if present) for shared local dev config # (STAGE_NAME, API_BASE_URL, STAGING_API_KEY, etc). Override any of these by # exporting them yourself before running this script. diff --git a/scripts/localStagingConceptsTesting/stageForProduction.sh b/scripts/localStagingConceptsTesting/stageForProduction.sh new file mode 100755 index 000000000..5bfb3acca --- /dev/null +++ b/scripts/localStagingConceptsTesting/stageForProduction.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +# Tests the stageConceptForProduction endpoint against a locally running MMT API +# (serverless-offline). +# +# Route (confirmed from local server startup log): +# POST {BASE_URL}/dev/providers/:providerId/:conceptType/:nativeId/stage-for-production +# +# This is the UAT-side forwarding Lambda. Locally, local-env.sh points +# PRODUCTION_API_HOST back at the same local API, so a successful run writes the +# posted metadata into the local S3 concepts bucket (via the createOrUpdateConcept +# route) and returns a productionUrl built from PRODUCTION_MMT_HOST. +# +# Sources local-env.sh (if present) for shared local dev config. +# +# Usage: +# ./stageForProduction.sh +# Stages a default sample collection as nativeId "TestStageForProd". +# +# ./stageForProduction.sh +# Stages the JSON body read from under . +# +# PROVIDER_ID=MMT_2 CONCEPT_TYPE=collections ./stageForProduction.sh TestCollection1 ./record.json + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -f "$SCRIPT_DIR/local-env.sh" ]; then + # shellcheck source=local-env.sh + source "$SCRIPT_DIR/local-env.sh" +fi + +BASE_URL="${BASE_URL:-${API_BASE_URL:-http://localhost:4001}}" +STAGE="${STAGE:-${STAGE_NAME:-dev}}" +# 'Bearer ABC-1' is a special test-mode token hardcoded in fetchProviders.js +# that grants access to MMT_1 and MMT_2 without needing real EDL/JWT auth. +AUTH_TOKEN="${AUTH_TOKEN:-Bearer ABC-1}" +PROVIDER_ID="${PROVIDER_ID:-MMT_1}" +CONCEPT_TYPE="${CONCEPT_TYPE:-collections}" + +NATIVE_ID="${1:-TestStageForProd}" + +if [ "$#" -ge 2 ]; then + RECORD_FILE_ARG="$2" + + if [ ! -f "$RECORD_FILE_ARG" ]; then + echo "FAIL: file not found: $RECORD_FILE_ARG" + exit 1 + fi + + BODY="$(cat "$RECORD_FILE_ARG")" +else + BODY=$(cat < { const response = await createOrUpdateConcept(event) expect(response.statusCode).toBe(200) + expect(JSON.parse(response.body)).toEqual({ + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + }) }) describe('when STAGING_API_KEY is not configured in the environment', () => { @@ -182,45 +187,6 @@ describe('createOrUpdateConcept', () => { }) }) - describe('when you do not have authorization to create or update', () => { - test('returns a status code 401', async () => { - const event = { - headers: validStagingHeaders, - body: JSON.stringify({ mock: 'Concept Body' }), - pathParameters: { - conceptType: 'collections', - nativeId: 'TestNativeId', - providerId: 'MMT_3' - } - } - - const response = await createOrUpdateConcept(event) - - expect(response.statusCode).toBe(401) - }) - }) - - describe('when fetching providers throws an error', () => { - test('returns a status code 500', async () => { - const event = { - headers: { - ...validStagingHeaders, - Authorization: 'Bearer invalid_token' - }, - body: JSON.stringify({ mock: 'Concept Body' }), - pathParameters: { - conceptType: 'collections', - nativeId: 'TestNativeId', - providerId: 'MMT_1' - } - } - - const response = await createOrUpdateConcept(event) - - expect(response.statusCode).toBe(500) - }) - }) - describe('when saving to s3 throws an error', () => { test('returns a status code 404', async () => { s3ClientMock.on(PutObjectCommand).rejects(new Error('S3 error')) diff --git a/serverless/src/createOrUpdateConcept/handler.js b/serverless/src/createOrUpdateConcept/handler.js index 58ee77df9..f70c212b3 100644 --- a/serverless/src/createOrUpdateConcept/handler.js +++ b/serverless/src/createOrUpdateConcept/handler.js @@ -4,12 +4,19 @@ import { getApplicationConfig } from '../../../sharedUtils/getConfig' import { getS3Client } from '../utils/getS3Client' import { getConceptsBucketName } from '../utils/getConceptsBucketName' import { s3ConceptTypes } from '../../../sharedConstants/s3ConceptTypes' -import fetchProviders from '../utils/fetchProviders' +import { safeCompareSecret } from '../utils/safeCompareSecret' let s3Client /** * Update (overwrite) a concept in S3 + * + * This is a machine-to-machine endpoint. In deployed environments API Gateway + * runs the `stagingApiKeyAuthorizer` in front of it; the in-handler + * `Staging-Api-Key` check below is kept because the local API runner + * (bin/api.mjs) does not invoke authorizers, so it is the only auth layer + * locally. Callers are trusted to be authorized for `providerId` (the MMT UAT + * forwarding Lambda performs the per-user provider check before forwarding). * @param {Object} event Details about the HTTP request that it received */ const createOrUpdateConcept = async (event) => { @@ -30,7 +37,7 @@ const createOrUpdateConcept = async (event) => { const [, stagingApiKey] = stagingApiKeyHeader || [] - if (!process.env.STAGING_API_KEY || stagingApiKey !== process.env.STAGING_API_KEY) { + if (!safeCompareSecret(stagingApiKey, process.env.STAGING_API_KEY)) { console.error('Missing or invalid Staging-Api-Key header') return { @@ -57,26 +64,6 @@ const createOrUpdateConcept = async (event) => { } } - try { - const providerIds = await fetchProviders(event) - - if (!providerIds.includes(providerId)) { - console.error(`Missing permissions for provider "${providerId}"`) - - return { - statusCode: 401, - headers: defaultResponseHeaders - } - } - } catch (error) { - console.log('Error fetching providers:', error) - - return { - statusCode: 500, - headers: defaultResponseHeaders - } - } - try { // S3 directory structure: s3BucketName/providerId/conceptType/nativeId.json const key = `${providerId}/${conceptType}/${nativeId}.json` @@ -95,7 +82,13 @@ const createOrUpdateConcept = async (event) => { return { statusCode, - headers: defaultResponseHeaders + headers: defaultResponseHeaders, + // Return the identifying tuple so the caller can build a deep link + body: JSON.stringify({ + conceptType, + nativeId, + providerId + }) } } catch (error) { console.log('updateConcept Error:', error) diff --git a/serverless/src/deleteConcept/__tests__/handler.test.js b/serverless/src/deleteConcept/__tests__/handler.test.js index d3fc459ab..332a8df30 100644 --- a/serverless/src/deleteConcept/__tests__/handler.test.js +++ b/serverless/src/deleteConcept/__tests__/handler.test.js @@ -5,18 +5,11 @@ import deleteConcept from '../handler' const s3ClientMock = mockClient(S3Client) -const validStagingHeaders = { - Authorization: 'Bearer ABC-1', - 'Staging-Api-Key': 'test-staging-key' -} - beforeEach(() => { vi.clearAllMocks() s3ClientMock.reset() vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) - - process.env.STAGING_API_KEY = 'test-staging-key' }) describe('deleteConcept', () => { @@ -33,7 +26,9 @@ describe('deleteConcept', () => { }) const event = { - headers: validStagingHeaders, + headers: { + Authorization: 'Bearer ABC-1' + }, pathParameters: { conceptType: 'collections', nativeId: 'TestNativeId', @@ -46,105 +41,12 @@ describe('deleteConcept', () => { expect(response.statusCode).toBe(204) }) - describe('when STAGING_API_KEY is not configured in the environment', () => { - test('returns a status code 401 even when no header is sent', async () => { - delete process.env.STAGING_API_KEY - - const event = { - headers: { - Authorization: 'Bearer ABC-1' - // No Staging-Api-Key header sent at all - }, - pathParameters: { - conceptType: 'collections', - nativeId: 'TestNativeId', - providerId: 'MMT_1' - } - } - - const response = await deleteConcept(event) - - expect(response.statusCode).toBe(401) - expect(s3ClientMock.commandCalls(DeleteObjectCommand)).toHaveLength(0) - }) - }) - - describe('when the Staging-Api-Key header is missing', () => { - test('returns a status code 401', async () => { + describe('when the conceptType is invalid', () => { + test('returns a status code 400', async () => { const event = { headers: { Authorization: 'Bearer ABC-1' }, - pathParameters: { - conceptType: 'collections', - nativeId: 'TestNativeId', - providerId: 'MMT_1' - } - } - - const response = await deleteConcept(event) - - expect(response.statusCode).toBe(401) - expect(s3ClientMock.commandCalls(DeleteObjectCommand)).toHaveLength(0) - }) - }) - - describe('when the Staging-Api-Key header does not match', () => { - test('returns a status code 401', async () => { - const event = { - headers: { - Authorization: 'Bearer ABC-1', - 'Staging-Api-Key': 'wrong-key' - }, - pathParameters: { - conceptType: 'collections', - nativeId: 'TestNativeId', - providerId: 'MMT_1' - } - } - - const response = await deleteConcept(event) - - expect(response.statusCode).toBe(401) - expect(s3ClientMock.commandCalls(DeleteObjectCommand)).toHaveLength(0) - }) - }) - - describe('when the Staging-Api-Key header has different casing', () => { - test('is still accepted (case-insensitive lookup)', async () => { - s3ClientMock.on(DeleteObjectCommand).resolves({ - $metadata: { - httpStatusCode: 204, - requestId: undefined, - extendedRequestId: undefined, - cfId: undefined, - attempts: 1, - totalRetryDelay: 0 - } - }) - - const event = { - headers: { - Authorization: 'Bearer ABC-1', - 'staging-api-key': 'test-staging-key' - }, - pathParameters: { - conceptType: 'collections', - nativeId: 'TestNativeId', - providerId: 'MMT_1' - } - } - - const response = await deleteConcept(event) - - expect(response.statusCode).toBe(204) - }) - }) - - describe('when the conceptType is invalid', () => { - test('returns a status code 400', async () => { - const event = { - headers: validStagingHeaders, pathParameters: { conceptType: 'invalid-type', nativeId: 'TestNativeId', @@ -162,7 +64,9 @@ describe('deleteConcept', () => { describe('when you do not have authorization to delete', () => { test('returns a status code 401', async () => { const event = { - headers: validStagingHeaders, + headers: { + Authorization: 'Bearer ABC-1' + }, pathParameters: { conceptType: 'collections', nativeId: 'TestNativeId', @@ -181,7 +85,6 @@ describe('deleteConcept', () => { test('returns a status code 404', async () => { const event = { headers: { - ...validStagingHeaders, Authorization: 'Bearer invalid_token' }, pathParameters: { @@ -194,6 +97,7 @@ describe('deleteConcept', () => { const response = await deleteConcept(event) expect(response.statusCode).toBe(404) + expect(s3ClientMock.commandCalls(DeleteObjectCommand)).toHaveLength(0) }) }) @@ -214,7 +118,9 @@ describe('deleteConcept', () => { }) const event = { - headers: validStagingHeaders, + headers: { + Authorization: 'Bearer ABC-1' + }, pathParameters: { conceptType: 'collections', nativeId: 'NonExistentNativeId', @@ -233,7 +139,9 @@ describe('deleteConcept', () => { s3ClientMock.on(DeleteObjectCommand).rejects(new Error('S3 error')) const event = { - headers: validStagingHeaders, + headers: { + Authorization: 'Bearer ABC-1' + }, pathParameters: { conceptType: 'collections', nativeId: 'TestNativeId', diff --git a/serverless/src/deleteConcept/handler.js b/serverless/src/deleteConcept/handler.js index 6257523e9..ec65b18eb 100644 --- a/serverless/src/deleteConcept/handler.js +++ b/serverless/src/deleteConcept/handler.js @@ -19,25 +19,9 @@ const deleteConcept = async (event) => { s3Client = getS3Client() } - const { headers, pathParameters } = event + const { pathParameters } = event const { conceptType, nativeId, providerId } = pathParameters - // Header casing isn't guaranteed by API Gateway/Lambda proxy integration, - // so look up 'Staging-Api-Key' case-insensitively - const stagingApiKeyHeader = Object.entries(headers || {}) - .find(([headerName]) => headerName.toLowerCase() === 'staging-api-key') - - const [, stagingApiKey] = stagingApiKeyHeader || [] - - if (!process.env.STAGING_API_KEY || stagingApiKey !== process.env.STAGING_API_KEY) { - console.error('Missing or invalid Staging-Api-Key header') - - return { - statusCode: 401, - headers: defaultResponseHeaders - } - } - if (!s3ConceptTypes.includes(conceptType)) { console.error(`Invalid conceptType "${conceptType}"`) diff --git a/serverless/src/getConcept/__tests__/handler.test.js b/serverless/src/getConcept/__tests__/handler.test.js index a7665eff3..343073711 100644 --- a/serverless/src/getConcept/__tests__/handler.test.js +++ b/serverless/src/getConcept/__tests__/handler.test.js @@ -5,18 +5,11 @@ import getConcept from '../handler' const s3ClientMock = mockClient(S3Client) -const validStagingHeaders = { - Authorization: 'Bearer ABC-1', - 'Staging-Api-Key': 'test-staging-key' -} - beforeEach(() => { vi.clearAllMocks() s3ClientMock.reset() vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) - - process.env.STAGING_API_KEY = 'test-staging-key' }) describe('getConcept', () => { @@ -38,7 +31,9 @@ describe('getConcept', () => { }) const event = { - headers: validStagingHeaders, + headers: { + Authorization: 'Bearer ABC-1' + }, pathParameters: { conceptType: 'collections', nativeId: 'TestNativeId', @@ -58,107 +53,12 @@ describe('getConcept', () => { }) }) - describe('when STAGING_API_KEY is not configured in the environment', () => { - test('returns a status code 401 even when no header is sent', async () => { - delete process.env.STAGING_API_KEY - - const event = { - headers: { - Authorization: 'Bearer ABC-1' - // No Staging-Api-Key header sent at all - }, - pathParameters: { - conceptType: 'collections', - nativeId: 'TestNativeId', - providerId: 'MMT_1' - } - } - - const response = await getConcept(event) - - expect(response.statusCode).toBe(401) - }) - }) - - describe('when the Staging-Api-Key header is missing', () => { - test('returns a status code 401', async () => { + describe('when the conceptType is invalid', () => { + test('returns a status code 400', async () => { const event = { headers: { Authorization: 'Bearer ABC-1' }, - pathParameters: { - conceptType: 'collections', - nativeId: 'TestNativeId', - providerId: 'MMT_1' - } - } - - const response = await getConcept(event) - - expect(response.statusCode).toBe(401) - }) - }) - - describe('when the Staging-Api-Key header does not match', () => { - test('returns a status code 401', async () => { - const event = { - headers: { - Authorization: 'Bearer ABC-1', - 'Staging-Api-Key': 'wrong-key' - }, - pathParameters: { - conceptType: 'collections', - nativeId: 'TestNativeId', - providerId: 'MMT_1' - } - } - - const response = await getConcept(event) - - expect(response.statusCode).toBe(401) - }) - }) - - describe('when the Staging-Api-Key header has different casing', () => { - test('is still accepted (case-insensitive lookup)', async () => { - const mockConcept = { mock: 'Concept Body' } - - s3ClientMock.on(GetObjectCommand).resolves({ - $metadata: { - httpStatusCode: 200, - requestId: undefined, - extendedRequestId: undefined, - cfId: undefined, - attempts: 1, - totalRetryDelay: 0 - }, - Body: { - transformToString: vi.fn().mockResolvedValue(JSON.stringify(mockConcept)) - } - }) - - const event = { - headers: { - Authorization: 'Bearer ABC-1', - 'staging-api-key': 'test-staging-key' - }, - pathParameters: { - conceptType: 'collections', - nativeId: 'TestNativeId', - providerId: 'MMT_1' - } - } - - const response = await getConcept(event) - - expect(response.statusCode).toBe(200) - }) - }) - - describe('when the conceptType is invalid', () => { - test('returns a status code 400', async () => { - const event = { - headers: validStagingHeaders, pathParameters: { conceptType: 'invalid-type', nativeId: 'TestNativeId', @@ -175,7 +75,9 @@ describe('getConcept', () => { describe('when you do not have authorization to retrieve', () => { test('returns a status code 401', async () => { const event = { - headers: validStagingHeaders, + headers: { + Authorization: 'Bearer ABC-1' + }, pathParameters: { conceptType: 'collections', nativeId: 'TestNativeId', @@ -193,7 +95,6 @@ describe('getConcept', () => { test('returns a status code 404', async () => { const event = { headers: { - ...validStagingHeaders, Authorization: 'Bearer invalid_token' }, pathParameters: { @@ -214,7 +115,9 @@ describe('getConcept', () => { s3ClientMock.on(GetObjectCommand).rejects(new Error('NoSuchKey')) const event = { - headers: validStagingHeaders, + headers: { + Authorization: 'Bearer ABC-1' + }, pathParameters: { conceptType: 'collections', nativeId: 'TestNativeId', diff --git a/serverless/src/getConcept/handler.js b/serverless/src/getConcept/handler.js index 350f3f629..59d6fb5a3 100644 --- a/serverless/src/getConcept/handler.js +++ b/serverless/src/getConcept/handler.js @@ -19,25 +19,9 @@ const getConcept = async (event) => { s3Client = getS3Client() } - const { headers, pathParameters } = event + const { pathParameters } = event const { conceptType, nativeId, providerId } = pathParameters - // Header casing isn't guaranteed by API Gateway/Lambda proxy integration, - // so look up 'Staging-Api-Key' case-insensitively - const stagingApiKeyHeader = Object.entries(headers || {}) - .find(([headerName]) => headerName.toLowerCase() === 'staging-api-key') - - const [, stagingApiKey] = stagingApiKeyHeader || [] - - if (!process.env.STAGING_API_KEY || stagingApiKey !== process.env.STAGING_API_KEY) { - console.error('Missing or invalid Staging-Api-Key header') - - return { - statusCode: 401, - headers: defaultResponseHeaders - } - } - if (!s3ConceptTypes.includes(conceptType)) { console.error(`Invalid conceptType "${conceptType}"`) diff --git a/serverless/src/getConcepts/__tests__/handler.test.js b/serverless/src/getConcepts/__tests__/handler.test.js index b53fe76b4..5df6fdb42 100644 --- a/serverless/src/getConcepts/__tests__/handler.test.js +++ b/serverless/src/getConcepts/__tests__/handler.test.js @@ -5,17 +5,10 @@ vi.mock('../../utils/s3ListObjects', () => ({ s3ListObjects: vi.fn() })) -const validStagingHeaders = { - Authorization: 'Bearer ABC-1', - 'Staging-Api-Key': 'test-staging-key' -} - beforeEach(() => { vi.clearAllMocks() vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {}) - - process.env.STAGING_API_KEY = 'test-staging-key' }) describe('getConcepts', () => { @@ -32,7 +25,9 @@ describe('getConcepts', () => { ]) const event = { - headers: validStagingHeaders, + headers: { + Authorization: 'Bearer ABC-1' + }, pathParameters: { conceptType: 'collections', providerId: 'MMT_1' @@ -59,96 +54,16 @@ describe('getConcepts', () => { ]) }) - describe('when STAGING_API_KEY is not configured in the environment', () => { - test('returns a status code 401 even when no header is sent', async () => { - delete process.env.STAGING_API_KEY - - const event = { - headers: { - Authorization: 'Bearer ABC-1' - // No Staging-Api-Key header sent at all - }, - pathParameters: { - conceptType: 'collections', - providerId: 'MMT_1' - } - } - - const response = await getConcepts(event) - - expect(response.statusCode).toBe(401) - expect(s3ListObjects).not.toHaveBeenCalled() - }) - }) - - describe('when the Staging-Api-Key header is missing', () => { - test('returns a status code 401', async () => { + describe('when pathParameters is missing', () => { + test('returns a status code 400', async () => { const event = { headers: { Authorization: 'Bearer ABC-1' - }, - pathParameters: { - conceptType: 'collections', - providerId: 'MMT_1' - } - } - - const response = await getConcepts(event) - - expect(response.statusCode).toBe(401) - expect(s3ListObjects).not.toHaveBeenCalled() - }) - }) - - describe('when the Prod-Staging-Api-Key header does not match', () => { - test('returns a status code 401', async () => { - const event = { - headers: { - Authorization: 'Bearer ABC-1', - 'Staging-Api-Key': 'wrong-key' - }, - pathParameters: { - conceptType: 'collections', - providerId: 'MMT_1' } } const response = await getConcepts(event) - expect(response.statusCode).toBe(401) - expect(s3ListObjects).not.toHaveBeenCalled() - }) - }) - - describe('when the Prod-Staging-Api-Key header has different casing', () => { - test('is still accepted (case-insensitive lookup)', async () => { - s3ListObjects.mockResolvedValue([]) - - const event = { - headers: { - Authorization: 'Bearer ABC-1', - 'staging-api-key': 'test-staging-key' - }, - pathParameters: { - conceptType: 'collections', - providerId: 'MMT_1' - } - } - - const response = await getConcepts(event) - - expect(response.statusCode).toBe(200) - }) - }) - - describe('when pathParameters is missing', () => { - test('returns a status code 400', async () => { - const event = { - headers: validStagingHeaders - } - - const response = await getConcepts(event) - expect(response.statusCode).toBe(400) }) }) @@ -156,7 +71,9 @@ describe('getConcepts', () => { describe('when the conceptType is invalid', () => { test('returns a status code 400', async () => { const event = { - headers: validStagingHeaders, + headers: { + Authorization: 'Bearer ABC-1' + }, pathParameters: { conceptType: 'invalid-type', providerId: 'MMT_1' @@ -172,7 +89,9 @@ describe('getConcepts', () => { describe('when you do not have authorization to list', () => { test('returns a status code 404', async () => { const event = { - headers: validStagingHeaders, + headers: { + Authorization: 'Bearer ABC-1' + }, pathParameters: { conceptType: 'collections', providerId: 'MMT_3' @@ -190,7 +109,6 @@ describe('getConcepts', () => { test('returns a status code 404', async () => { const event = { headers: { - ...validStagingHeaders, Authorization: 'Bearer invalid_token' }, pathParameters: { @@ -210,7 +128,9 @@ describe('getConcepts', () => { s3ListObjects.mockRejectedValue(new Error('S3 error')) const event = { - headers: validStagingHeaders, + headers: { + Authorization: 'Bearer ABC-1' + }, pathParameters: { conceptType: 'collections', providerId: 'MMT_1' @@ -228,7 +148,9 @@ describe('getConcepts', () => { s3ListObjects.mockResolvedValue([]) const event = { - headers: validStagingHeaders, + headers: { + Authorization: 'Bearer ABC-1' + }, pathParameters: { conceptType: 'collections', providerId: 'MMT_1' diff --git a/serverless/src/getConcepts/handler.js b/serverless/src/getConcepts/handler.js index bb4f6b8c1..c098658ba 100644 --- a/serverless/src/getConcepts/handler.js +++ b/serverless/src/getConcepts/handler.js @@ -18,25 +18,9 @@ const getConcepts = async (event) => { s3Client = getS3Client() } - const { headers, pathParameters } = event + const { pathParameters } = event const { conceptType, providerId } = pathParameters || {} - // Header casing isn't guaranteed by API Gateway/Lambda proxy integration, - // so look up 'Staging-Api-Key' case-insensitively - const stagingApiKeyHeader = Object.entries(headers || {}) - .find(([headerName]) => headerName.toLowerCase() === 'staging-api-key') - - const [, stagingApiKey] = stagingApiKeyHeader || [] - - if (!process.env.STAGING_API_KEY || stagingApiKey !== process.env.STAGING_API_KEY) { - console.error('Missing or invalid Staging-Api-Key header') - - return { - statusCode: 401, - headers: defaultResponseHeaders - } - } - if (!s3ConceptTypes.includes(conceptType)) { console.error(`Invalid conceptType "${conceptType}"`) diff --git a/serverless/src/stageConceptForProduction/__tests__/handler.test.js b/serverless/src/stageConceptForProduction/__tests__/handler.test.js new file mode 100644 index 000000000..aa1557699 --- /dev/null +++ b/serverless/src/stageConceptForProduction/__tests__/handler.test.js @@ -0,0 +1,141 @@ +import stageConceptForProduction from '../handler' + +const validEvent = { + body: JSON.stringify({ + ShortName: 'Test', + Version: '1' + }), + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1' + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + process.env.PRODUCTION_API_HOST = 'https://prod.example.com/prod' + process.env.PRODUCTION_MMT_HOST = 'https://mmt.example.com' + process.env.PRODUCTION_STAGING_API_KEY = 'prod-staging-key' +}) + +describe('stageConceptForProduction', () => { + test('forwards the metadata to production and returns a production link', async () => { + global.fetch = vi.fn(() => Promise.resolve({ + ok: true, + status: 200 + })) + + const response = await stageConceptForProduction(validEvent) + + expect(response.statusCode).toBe(200) + expect(JSON.parse(response.body)).toEqual({ + conceptType: 'collections', + nativeId: 'TestNativeId', + providerId: 'MMT_1', + productionUrl: 'https://mmt.example.com/providers/MMT_1/collections/TestNativeId' + }) + + expect(global.fetch).toHaveBeenCalledWith( + 'https://prod.example.com/prod/providers/MMT_1/collections/TestNativeId', + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ 'Staging-Api-Key': 'prod-staging-key' }), + body: validEvent.body + }) + ) + }) + + describe('when the conceptType is invalid', () => { + test('returns a status code 400', async () => { + const response = await stageConceptForProduction({ + ...validEvent, + pathParameters: { + ...validEvent.pathParameters, + conceptType: 'invalid-type' + } + }) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('when the request body is missing', () => { + test('returns a status code 400', async () => { + const response = await stageConceptForProduction({ + ...validEvent, + body: undefined + }) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('when the user is not authorized for the provider', () => { + test('returns a status code 403', async () => { + const response = await stageConceptForProduction({ + ...validEvent, + pathParameters: { + ...validEvent.pathParameters, + providerId: 'MMT_3' + } + }) + + expect(response.statusCode).toBe(403) + }) + }) + + describe('when fetching providers throws an error', () => { + test('returns a status code 500', async () => { + const response = await stageConceptForProduction({ + ...validEvent, + headers: { Authorization: 'Bearer invalid_token' } + }) + + expect(response.statusCode).toBe(500) + }) + }) + + describe('when production promotion is not configured', () => { + test('returns a status code 500', async () => { + delete process.env.PRODUCTION_API_HOST + + const response = await stageConceptForProduction(validEvent) + + expect(response.statusCode).toBe(500) + }) + }) + + describe('when production rejects the request', () => { + test('returns a status code 502', async () => { + global.fetch = vi.fn(() => Promise.resolve({ + ok: false, + status: 401 + })) + + const response = await stageConceptForProduction(validEvent) + + expect(response.statusCode).toBe(502) + expect(JSON.parse(response.body)).toEqual({ + error: 'Production rejected the request with status 401' + }) + }) + }) + + describe('when the forward call throws', () => { + test('returns a status code 502', async () => { + global.fetch = vi.fn(() => Promise.reject(new Error('Network down'))) + + const response = await stageConceptForProduction(validEvent) + + expect(response.statusCode).toBe(502) + expect(JSON.parse(response.body)).toEqual({ error: 'Error: Network down' }) + }) + }) +}) diff --git a/serverless/src/stageConceptForProduction/handler.js b/serverless/src/stageConceptForProduction/handler.js new file mode 100644 index 000000000..bfece072d --- /dev/null +++ b/serverless/src/stageConceptForProduction/handler.js @@ -0,0 +1,122 @@ +import { getApplicationConfig } from '../../../sharedUtils/getConfig' +import { s3ConceptTypes } from '../../../sharedConstants/s3ConceptTypes' +import fetchProviders from '../utils/fetchProviders' + +/** + * Forwards a collection's metadata from MMT UAT to the Production API Gateway so + * it can be staged for production. + * + * This Lambda runs in UAT behind the EDL authorizer (a real browser user). It + * verifies the user may act for the given provider, then calls the Production + * `createOrUpdateConcept` endpoint using the Production staging API key held in + * an environment variable (so the key never reaches the browser). On success it + * returns a link the user can follow to continue the workflow in Production. + * @param {Object} event Details about the HTTP request that it received + */ +const stageConceptForProduction = async (event) => { + const { defaultResponseHeaders } = getApplicationConfig() + + const { body, pathParameters } = event + const { conceptType, nativeId, providerId } = pathParameters || {} + + if (!s3ConceptTypes.includes(conceptType)) { + console.error(`Invalid conceptType "${conceptType}"`) + + return { + statusCode: 400, + headers: defaultResponseHeaders + } + } + + if (!body) { + console.error('Missing request body') + + return { + statusCode: 400, + headers: defaultResponseHeaders + } + } + + try { + const allowedProviderIds = await fetchProviders(event) + + if (!allowedProviderIds.includes(providerId)) { + console.error(`Missing permissions for provider "${providerId}"`) + + return { + statusCode: 403, + headers: defaultResponseHeaders + } + } + } catch (error) { + console.log('Error fetching providers:', error) + + return { + statusCode: 500, + headers: defaultResponseHeaders + } + } + + const { + PRODUCTION_API_HOST: productionApiHost, + PRODUCTION_MMT_HOST: productionMmtHost, + PRODUCTION_STAGING_API_KEY: productionStagingApiKey + } = process.env + + if (!productionApiHost || !productionStagingApiKey) { + console.error('Production promotion is not configured for this environment') + + return { + statusCode: 500, + headers: defaultResponseHeaders + } + } + + const productionUrl = `${productionApiHost}/providers/${providerId}/${conceptType}/${nativeId}` + + try { + const response = await fetch(productionUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Staging-Api-Key': productionStagingApiKey + }, + body + }) + + if (!response.ok) { + console.error(`Production responded with status ${response.status} staging "${providerId}/${conceptType}/${nativeId}"`) + + return { + statusCode: 502, + headers: defaultResponseHeaders, + body: JSON.stringify({ + error: `Production rejected the request with status ${response.status}` + }) + } + } + + return { + statusCode: 200, + headers: defaultResponseHeaders, + body: JSON.stringify({ + conceptType, + nativeId, + providerId, + productionUrl: `${productionMmtHost}/providers/${providerId}/${conceptType}/${nativeId}` + }) + } + } catch (error) { + console.error(`Error staging concept for production: ${error.toString()}`) + + return { + statusCode: 502, + headers: defaultResponseHeaders, + body: JSON.stringify({ + error: error.toString() + }) + } + } +} + +export default stageConceptForProduction diff --git a/serverless/src/stagingApiKeyAuthorizer/__tests__/handler.test.js b/serverless/src/stagingApiKeyAuthorizer/__tests__/handler.test.js new file mode 100644 index 000000000..46490f046 --- /dev/null +++ b/serverless/src/stagingApiKeyAuthorizer/__tests__/handler.test.js @@ -0,0 +1,113 @@ +import stagingApiKeyAuthorizer from '../handler' + +const methodArn = 'arn:aws:execute-api:us-east-1:123456789012:api-id/stage/PUT/providers/MMT_1/collections/TestNativeId' + +describe('stagingApiKeyAuthorizer', () => { + const OLD_ENV = process.env + + beforeEach(() => { + process.env = { ...OLD_ENV } + delete process.env.IS_OFFLINE + + vi.spyOn(console, 'error').mockImplementation(() => {}) + + process.env.STAGING_API_KEY = 'test-staging-key' + }) + + afterEach(() => { + process.env = OLD_ENV + }) + + describe('when running offline', () => { + test('returns an Allow policy without checking the key', async () => { + process.env.IS_OFFLINE = 'true' + delete process.env.STAGING_API_KEY + + const response = await stagingApiKeyAuthorizer({ + headers: {}, + methodArn + }) + + expect(response).toEqual({ + principalId: 'offline', + policyDocument: { + Version: '2012-10-17', + Statement: [ + { + Action: 'execute-api:Invoke', + Effect: 'Allow', + Resource: methodArn + } + ] + } + }) + }) + }) + + describe('when the Staging-Api-Key header matches', () => { + test('returns an Allow policy', async () => { + const response = await stagingApiKeyAuthorizer({ + headers: { 'Staging-Api-Key': 'test-staging-key' }, + methodArn + }) + + expect(response).toEqual({ + principalId: 'staging-api-client', + policyDocument: { + Version: '2012-10-17', + Statement: [ + { + Action: 'execute-api:Invoke', + Effect: 'Allow', + Resource: methodArn + } + ] + } + }) + }) + + test('is accepted regardless of header casing', async () => { + const response = await stagingApiKeyAuthorizer({ + headers: { 'staging-api-key': 'test-staging-key' }, + methodArn + }) + + expect(response.principalId).toBe('staging-api-client') + }) + }) + + describe('when the Staging-Api-Key header is missing', () => { + test('throws Unauthorized', async () => { + await expect( + stagingApiKeyAuthorizer({ + headers: {}, + methodArn + }) + ).rejects.toThrow('Unauthorized') + }) + }) + + describe('when the Staging-Api-Key header does not match', () => { + test('throws Unauthorized', async () => { + await expect( + stagingApiKeyAuthorizer({ + headers: { 'Staging-Api-Key': 'wrong-key' }, + methodArn + }) + ).rejects.toThrow('Unauthorized') + }) + }) + + describe('when STAGING_API_KEY is not configured in the environment', () => { + test('throws Unauthorized even when the header matches an empty value', async () => { + delete process.env.STAGING_API_KEY + + await expect( + stagingApiKeyAuthorizer({ + headers: {}, + methodArn + }) + ).rejects.toThrow('Unauthorized') + }) + }) +}) diff --git a/serverless/src/stagingApiKeyAuthorizer/handler.js b/serverless/src/stagingApiKeyAuthorizer/handler.js new file mode 100644 index 000000000..6c680eb49 --- /dev/null +++ b/serverless/src/stagingApiKeyAuthorizer/handler.js @@ -0,0 +1,36 @@ +import { generatePolicy } from '../utils/authorizer/generatePolicy' +import { downcaseKeys } from '../utils/downcaseKeys' +import { safeCompareSecret } from '../utils/safeCompareSecret' + +/** + * Custom API Gateway authorizer for the machine-to-machine "staging concepts" + * endpoints. It authenticates the caller solely by a shared secret sent in the + * `Staging-Api-Key` header (compared against `process.env.STAGING_API_KEY`). + * + * This replaces the EDL authorizer on the concept routes: those requests come + * from the MMT UAT forwarding Lambda, not from a browser user with an EDL token. + * @param {Object} event Details about the HTTP request that it received + */ +const stagingApiKeyAuthorizer = async (event) => { + const { headers = {}, methodArn } = event + + // Allow local development invocations to bypass auth. The local API runner + // (bin/api.mjs) never invokes authorizers, so this only matters if the + // authorizer handler is exercised directly. + if (process.env.IS_OFFLINE) { + return generatePolicy('offline', 'Allow', methodArn) + } + + const { 'staging-api-key': stagingApiKey } = downcaseKeys(headers) + + // Fail closed when the expected key is not configured in the environment. + if (!safeCompareSecret(stagingApiKey, process.env.STAGING_API_KEY)) { + console.error('Missing or invalid Staging-Api-Key header') + + throw new Error('Unauthorized') + } + + return generatePolicy('staging-api-client', 'Allow', methodArn) +} + +export default stagingApiKeyAuthorizer diff --git a/serverless/src/utils/__tests__/safeCompareSecret.test.js b/serverless/src/utils/__tests__/safeCompareSecret.test.js new file mode 100644 index 000000000..c81b72c48 --- /dev/null +++ b/serverless/src/utils/__tests__/safeCompareSecret.test.js @@ -0,0 +1,31 @@ +import { safeCompareSecret } from '../safeCompareSecret' + +describe('safeCompareSecret', () => { + test('returns true when the values match exactly', () => { + expect(safeCompareSecret('super-secret-key', 'super-secret-key')).toBe(true) + }) + + test('returns false when the values differ', () => { + expect(safeCompareSecret('super-secret-key', 'wrong-secret-key')).toBe(false) + }) + + test('returns false when the values differ only in length', () => { + expect(safeCompareSecret('super-secret-key', 'super-secret-key-extra')).toBe(false) + }) + + test('returns false when the provided value is undefined', () => { + expect(safeCompareSecret(undefined, 'super-secret-key')).toBe(false) + }) + + test('returns false when the expected value is undefined', () => { + expect(safeCompareSecret('super-secret-key', undefined)).toBe(false) + }) + + test('returns false when both values are empty strings', () => { + expect(safeCompareSecret('', '')).toBe(false) + }) + + test('returns false when the provided value is not a string', () => { + expect(safeCompareSecret({ key: 'super-secret-key' }, 'super-secret-key')).toBe(false) + }) +}) diff --git a/serverless/src/utils/safeCompareSecret.js b/serverless/src/utils/safeCompareSecret.js new file mode 100644 index 000000000..8caa16e0d --- /dev/null +++ b/serverless/src/utils/safeCompareSecret.js @@ -0,0 +1,26 @@ +import { timingSafeEqual } from 'crypto' + +/** + * Compares a caller-supplied secret against the expected value in constant time. + * + * A plain `===` returns as soon as the first differing byte is found, which + * leaks how much of the secret a caller guessed correctly. `timingSafeEqual` + * always compares the full buffers. It throws when the buffers differ in + * length, so the length check (itself not secret) happens first. + * @param {string} provided The value received from the request + * @param {string} expected The configured secret to compare against + * @returns {boolean} true only when both are non-empty strings of equal value + */ +export const safeCompareSecret = (provided, expected) => { + if (typeof provided !== 'string' || typeof expected !== 'string') return false + if (provided.length === 0 || expected.length === 0) return false + + const providedBuffer = Buffer.from(provided) + const expectedBuffer = Buffer.from(expected) + + if (providedBuffer.length !== expectedBuffer.length) return false + + return timingSafeEqual(providedBuffer, expectedBuffer) +} + +export default safeCompareSecret From 484bab2f210574b8c72ad9d2578462765c04c967 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Tue, 8 Sep 2026 17:30:41 -0400 Subject: [PATCH 10/18] MMT-4199: Add bamboo var plan --- docs/stage-for-production-env-vars.md | 106 ++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/stage-for-production-env-vars.md diff --git a/docs/stage-for-production-env-vars.md b/docs/stage-for-production-env-vars.md new file mode 100644 index 000000000..96006f200 --- /dev/null +++ b/docs/stage-for-production-env-vars.md @@ -0,0 +1,106 @@ +# Stage-for-Production: environment variables per environment + +The cross-environment "Stage for Production" promotion flow (`MMT-4199`) introduces +five environment variables. This document describes how to set them across the +three deployed environments — **SIT**, **UAT**, and **PROD** — where SIT is a test +bed and UAT → PROD is the real promotion path. + +## The one invariant + +The flow is a one-directional "push upward" chain. The single rule that must hold: + +> **The sender's `PRODUCTION_STAGING_API_KEY` must be byte-for-byte equal to the +> receiver's `STAGING_API_KEY`.** + +That shared secret is the only credential in front of the machine-to-machine +`PUT /providers/{providerId}/{conceptType}/{nativeId}` route — it is verified by the +`stagingApiKeyAuthorizer` API Gateway authorizer and re-checked inside the +`createOrUpdateConcept` handler. + +## The variables + +| Variable | Role | Set via (Bamboo) | +|---|---|---| +| `STAGING_API_KEY` | **Inbound** secret this environment accepts on the `Staging-Api-Key` header | `bamboo_STAGING_API_KEY` (secret) | +| `STAGING_CONCEPTS_BUCKET_NAME` | This environment's concepts bucket | `bamboo_STAGING_CONCEPTS_BUCKET_NAME` — leave at the `mmt-${STAGE_NAME}-staging-concepts` default | +| `PRODUCTION_API_HOST` | **Outbound** — API Gateway base URL the `stageConceptForProduction` Lambda `PUT`s to. Empty ⇒ the handler returns `500` (promotion disabled) | `bamboo_PRODUCTION_API_HOST` | +| `PRODUCTION_MMT_HOST` | UI host used to build the deep link returned to the browser (`productionUrl` in the response) | `bamboo_PRODUCTION_MMT_HOST` | +| `PRODUCTION_STAGING_API_KEY` | **Outbound** secret sent to the target environment; must equal the target's `STAGING_API_KEY` | `bamboo_PRODUCTION_STAGING_API_KEY` (secret) | + +`deploy-bamboo.sh` already forwards all five `bamboo_*` variables through +Docker → CDK → Lambda, so the only work is defining the plan variables in each +environment's Bamboo deploy plan (mark the two key variables as secret). + +## Per environment + +### PROD — final destination (receives, never forwards) + +| Variable | Value | +|---|---| +| `STAGING_API_KEY` | `` — real, unique, non-placeholder. This is the key UAT uses to push in. | +| `STAGING_CONCEPTS_BUCKET_NAME` | default (`mmt-prod-staging-concepts`) | +| `PRODUCTION_API_HOST` | **unset / empty** | +| `PRODUCTION_MMT_HOST` | **unset / empty** | +| `PRODUCTION_STAGING_API_KEY` | leave unset — the synth guard only fires when `PRODUCTION_API_HOST` is also set | + +### UAT — real promotion source → PROD + +| Variable | Value | +|---|---| +| `STAGING_API_KEY` | `` — real, unique. Used if you also test SIT → UAT, and good hygiene regardless. | +| `STAGING_CONCEPTS_BUCKET_NAME` | default (`mmt-uat-staging-concepts`) | +| `PRODUCTION_API_HOST` | PROD's API Gateway base URL | +| `PRODUCTION_MMT_HOST` | PROD's MMT UI host | +| `PRODUCTION_STAGING_API_KEY` | **exactly** PROD's `STAGING_API_KEY` | + +### SIT — test bed + +SIT has two viable configurations for what it promotes into. + +**Option A — SIT → UAT (recommended).** Exercises the real +cross-account / VPC → private-API-Gateway path, which is the riskiest part of the +flow. Downside: it writes transient staged concepts into UAT's bucket (they +self-expire after 30 days). + +| Variable | Value | +|---|---| +| `STAGING_API_KEY` | `` — real, unique | +| `STAGING_CONCEPTS_BUCKET_NAME` | default (`mmt-sit-staging-concepts`) | +| `PRODUCTION_API_HOST` | UAT's API Gateway base URL | +| `PRODUCTION_MMT_HOST` | UAT's MMT UI host | +| `PRODUCTION_STAGING_API_KEY` | **exactly** UAT's `STAGING_API_KEY` | + +**Option B — SIT → SIT loopback.** Self-contained, does not touch UAT, but does +**not** test cross-account networking. This is what +`scripts/localStagingConceptsTesting/local-env.sh` does locally, so it is a +known-good configuration. + +| Variable | Value | +|---|---| +| `STAGING_API_KEY` | `` | +| `STAGING_CONCEPTS_BUCKET_NAME` | default (`mmt-sit-staging-concepts`) | +| `PRODUCTION_API_HOST` | SIT's own API Gateway base URL | +| `PRODUCTION_MMT_HOST` | SIT's own MMT UI host | +| `PRODUCTION_STAGING_API_KEY` | SIT's own `STAGING_API_KEY` (same value) | + +## Notes and gotchas + +- **Synth guard** (`cdk/mmt/lib/mmt-stack.ts`): when `NODE_ENV=production` (every + Bamboo deploy sets this), `cdk synth` **throws** if: + - `STAGING_API_KEY` is missing or still the source-controlled placeholder + `local-staging-api-key`; or + - `PRODUCTION_API_HOST` is set while `PRODUCTION_STAGING_API_KEY` is missing or + the placeholder. + + So each deployed plan must define real secrets or the deploy fails fast. +- Use **three distinct random secrets**, one per environment's `STAGING_API_KEY`. + UAT's `PRODUCTION_STAGING_API_KEY` is a copy of PROD's secret; SIT's (Option A) + is a copy of UAT's — the same secret under two names, not a fourth secret. +- **Networking is not code.** The UAT Lambda is in-VPC and must be able to reach + PROD's private API Gateway — this likely needs PrivateLink / VPC peering / a + regional endpoint. The same applies to SIT → UAT (Option A). Option B + (SIT → SIT) still calls SIT's own API Gateway but stays within one account. +- `STAGING_CONCEPTS_BUCKET_NAME` normally needs no override — the CDK stack + creates `mmt-${STAGE_NAME}-staging-concepts` (30-day object expiration, + `RemovalPolicy: RETAIN`, public access blocked, SSE-S3). If that bucket was + ever pre-created manually, `cdk import` or delete it before the first deploy. From 07aa3fc62d8d813ef3129ea441d866ecff3907f1 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Tue, 8 Sep 2026 17:45:11 -0400 Subject: [PATCH 11/18] MMT-4199: Add test --- .../src/getConcepts/__tests__/handler.test.js | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/serverless/src/getConcepts/__tests__/handler.test.js b/serverless/src/getConcepts/__tests__/handler.test.js index 5df6fdb42..e88d15761 100644 --- a/serverless/src/getConcepts/__tests__/handler.test.js +++ b/serverless/src/getConcepts/__tests__/handler.test.js @@ -15,12 +15,16 @@ describe('getConcepts', () => { test('retrieves a sorted list of concepts from s3', async () => { s3ListObjects.mockResolvedValue([ { - Key: 'MMT_1/collections/Zebra.json', - LastModified: '2024-01-02T00:00:00.000Z' + Key: 'MMT_1/collections/Mango.json', + LastModified: '2024-01-03T00:00:00.000Z' }, { Key: 'MMT_1/collections/Apple.json', LastModified: '2024-01-01T00:00:00.000Z' + }, + { + Key: 'MMT_1/collections/Zebra.json', + LastModified: '2024-01-02T00:00:00.000Z' } ]) @@ -45,6 +49,12 @@ describe('getConcepts', () => { nativeId: 'Apple', providerId: 'MMT_1' }, + { + conceptType: 'collections', + lastModified: '2024-01-03T00:00:00.000Z', + nativeId: 'Mango', + providerId: 'MMT_1' + }, { conceptType: 'collections', lastModified: '2024-01-02T00:00:00.000Z', @@ -54,6 +64,40 @@ describe('getConcepts', () => { ]) }) + test('keeps input order for concepts whose nativeIds differ only in case', async () => { + s3ListObjects.mockResolvedValue([ + { + Key: 'MMT_1/collections/test.json', + LastModified: '2024-01-02T00:00:00.000Z' + }, + { + Key: 'MMT_1/collections/Test.json', + LastModified: '2024-01-01T00:00:00.000Z' + } + ]) + + const event = { + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + providerId: 'MMT_1' + } + } + + const response = await getConcepts(event) + + expect(response.statusCode).toBe(200) + + // `TEST` === `TEST`, so the comparator returns 0; Array.prototype.sort is + // stable, so the two entries stay in the order S3 returned them. + expect(JSON.parse(response.body).map(({ nativeId }) => nativeId)).toEqual([ + 'test', + 'Test' + ]) + }) + describe('when pathParameters is missing', () => { test('returns a status code 400', async () => { const event = { From aec4bb8abd5912879ff793cd47d82736b1e3f5d9 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Tue, 8 Sep 2026 19:03:29 -0400 Subject: [PATCH 12/18] MMT-4199: Update comments --- cdk/mmt/lib/mmt-functions.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/cdk/mmt/lib/mmt-functions.ts b/cdk/mmt/lib/mmt-functions.ts index fbe4da4a3..1b5cdff83 100644 --- a/cdk/mmt/lib/mmt-functions.ts +++ b/cdk/mmt/lib/mmt-functions.ts @@ -301,11 +301,6 @@ export class MmtFunctions extends Construct { }) // createOrUpdateConcept - PUT /providers/{providerId}/{conceptType}/{nativeId} - // The only machine-to-machine concept route: it is called cross-environment - // by the UAT `stageConceptForProduction` Lambda using the shared staging API - // key, so it sits behind `stagingApiKeyAuthorizer` rather than the EDL - // authorizer. The read/list/delete routes above stay EDL-authenticated - // (real browser users) and keep their per-user `fetchProviders` check. new application.NodeJsFunction(new cdk.NestedStack(scope, 'CreateOrUpdateConceptNestedStack'), 'CreateOrUpdateConceptLambda', { ...defaultLambdaConfig, api: { @@ -346,8 +341,6 @@ export class MmtFunctions extends Construct { }) // stageConceptForProduction - POST /providers/{providerId}/{conceptType}/{nativeId}/stage-for-production - // UAT-only: forwards collection metadata to the Production API Gateway using - // the Production staging API key held in an environment variable. new application.NodeJsFunction(new cdk.NestedStack(scope, 'StageConceptForProductionNestedStack'), 'StageConceptForProductionLambda', { ...defaultLambdaConfig, api: { From 30ee3a73d3e6a785cfbee41f93b9f2ee91a5f005 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Thu, 10 Sep 2026 16:03:57 -0400 Subject: [PATCH 13/18] MMT-4199: Update for change in using only recordId as key --- cdk/mmt/lib/mmt-functions.ts | 36 ++--- .../lib/mmt-shared-api-gateway-resources.ts | 40 ++++-- .../deleteConcept.sh | 104 +++++--------- .../localStagingConceptsTesting/getConcept.sh | 102 ++++++-------- .../getConcepts.sh | 73 ++++------ .../postConcepts.sh | 68 +++++---- .../stageForProduction.sh | 41 ++++-- .../__tests__/handler.test.js | 45 +++--- .../src/createOrUpdateConcept/handler.js | 25 ++-- .../deleteConcept/__tests__/handler.test.js | 68 ++------- serverless/src/deleteConcept/handler.js | 24 ++-- .../src/getConcept/__tests__/handler.test.js | 63 ++------- serverless/src/getConcept/handler.js | 28 ++-- .../src/getConcepts/__tests__/handler.test.js | 130 +++--------------- serverless/src/getConcepts/handler.js | 42 ++---- .../__tests__/handler.test.js | 27 ++-- .../src/stageConceptForProduction/handler.js | 22 +-- 17 files changed, 329 insertions(+), 609 deletions(-) mode change 100755 => 100644 scripts/localStagingConceptsTesting/stageForProduction.sh diff --git a/cdk/mmt/lib/mmt-functions.ts b/cdk/mmt/lib/mmt-functions.ts index 1b5cdff83..a3cd0b47c 100644 --- a/cdk/mmt/lib/mmt-functions.ts +++ b/cdk/mmt/lib/mmt-functions.ts @@ -264,16 +264,16 @@ export class MmtFunctions extends Construct { role: s3LambdaRole }) - // getConcepts - GET /providers/{providerId}/{conceptType} + // getConcepts - GET /staged/{conceptType} new application.NodeJsFunction(new cdk.NestedStack(scope, 'GetConceptsNestedStack'), 'GetConceptsLambda', { ...defaultLambdaConfig, api: { apiGatewayDeployment, - apiGatewayResource: resources.providersConceptTypeResource, + apiGatewayResource: resources.stagedConceptTypeResource, apiGatewayRestApi, authorizer: authorizers.edlAuthorizer, methods: ['GET'], - parentPath: 'providersProviderIdVar', + parentPath: 'staged', path: '{conceptType}' }, entry: '../../serverless/src/getConcepts/handler.js', @@ -282,17 +282,17 @@ export class MmtFunctions extends Construct { role: s3LambdaRole }) - // getConcept - GET /providers/{providerId}/{conceptType}/{nativeId} + // getConcept - GET /staged/{conceptType}/{recordId} new application.NodeJsFunction(new cdk.NestedStack(scope, 'GetConceptNestedStack'), 'GetConceptLambda', { ...defaultLambdaConfig, api: { apiGatewayDeployment, - apiGatewayResource: resources.providersConceptTypeNativeIdResource, + apiGatewayResource: resources.stagedConceptTypeRecordIdResource, apiGatewayRestApi, authorizer: authorizers.edlAuthorizer, methods: ['GET'], - parentPath: 'providersProviderIdVarConceptTypeVar', - path: '{nativeId}' + parentPath: 'stagedConceptTypeVar', + path: '{recordId}' }, entry: '../../serverless/src/getConcept/handler.js', functionName: 'getConcept', @@ -300,17 +300,17 @@ export class MmtFunctions extends Construct { role: s3LambdaRole }) - // createOrUpdateConcept - PUT /providers/{providerId}/{conceptType}/{nativeId} + // createOrUpdateConcept - PUT /staged/{conceptType} new application.NodeJsFunction(new cdk.NestedStack(scope, 'CreateOrUpdateConceptNestedStack'), 'CreateOrUpdateConceptLambda', { ...defaultLambdaConfig, api: { apiGatewayDeployment, - apiGatewayResource: resources.providersConceptTypeNativeIdResource, + apiGatewayResource: resources.stagedConceptTypeResource, apiGatewayRestApi, authorizer: authorizers.stagingApiKeyAuthorizer, methods: ['PUT'], - parentPath: 'providersProviderIdVarConceptTypeVar', - path: '{nativeId}' + parentPath: 'staged', + path: '{conceptType}' }, entry: '../../serverless/src/createOrUpdateConcept/handler.js', environment: { @@ -322,17 +322,17 @@ export class MmtFunctions extends Construct { role: s3LambdaRole }) - // deleteConcept - DELETE /providers/{providerId}/{conceptType}/{nativeId} + // deleteConcept - DELETE /staged/{conceptType}/{recordId} new application.NodeJsFunction(new cdk.NestedStack(scope, 'DeleteConceptNestedStack'), 'DeleteConceptLambda', { ...defaultLambdaConfig, api: { apiGatewayDeployment, - apiGatewayResource: resources.providersConceptTypeNativeIdResource, + apiGatewayResource: resources.stagedConceptTypeRecordIdResource, apiGatewayRestApi, authorizer: authorizers.edlAuthorizer, methods: ['DELETE'], - parentPath: 'providersProviderIdVarConceptTypeVar', - path: '{nativeId}' + parentPath: 'stagedConceptTypeVar', + path: '{recordId}' }, entry: '../../serverless/src/deleteConcept/handler.js', functionName: 'deleteConcept', @@ -340,16 +340,16 @@ export class MmtFunctions extends Construct { role: s3LambdaRole }) - // stageConceptForProduction - POST /providers/{providerId}/{conceptType}/{nativeId}/stage-for-production + // stageConceptForProduction - POST /providers/{providerId}/{conceptType}/stage-for-production new application.NodeJsFunction(new cdk.NestedStack(scope, 'StageConceptForProductionNestedStack'), 'StageConceptForProductionLambda', { ...defaultLambdaConfig, api: { apiGatewayDeployment, - apiGatewayResource: resources.providersConceptTypeNativeIdStageForProductionResource, + apiGatewayResource: resources.providersConceptTypeStageForProductionResource, apiGatewayRestApi, authorizer: authorizers.edlAuthorizer, methods: ['POST'], - parentPath: 'providersProviderIdVarConceptTypeVarNativeIdVar', + parentPath: 'providersProviderIdVarConceptTypeVar', path: 'stage-for-production' }, entry: '../../serverless/src/stageConceptForProduction/handler.js', diff --git a/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts b/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts index 971659e6b..deec44243 100644 --- a/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts +++ b/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts @@ -26,8 +26,9 @@ export class MmtApiResources extends Construct { public readonly gkrKeywordRecommendationsResource: apigateway.CfnResource public readonly gkrSendFeedbackResource: apigateway.CfnResource public readonly providersConceptTypeResource: apigateway.CfnResource - public readonly providersConceptTypeNativeIdResource: apigateway.CfnResource - public readonly providersConceptTypeNativeIdStageForProductionResource: apigateway.CfnResource + public readonly providersConceptTypeStageForProductionResource: apigateway.CfnResource + public readonly stagedConceptTypeResource: apigateway.CfnResource + public readonly stagedConceptTypeRecordIdResource: apigateway.CfnResource public readonly providersTemplatesResource: apigateway.CfnResource public readonly providersTemplatesIdResource: apigateway.CfnResource public readonly templatesResource: apigateway.CfnResource @@ -121,6 +122,9 @@ export class MmtApiResources extends Construct { }) this.providersTemplatesIdResource = providersTemplatesIdResource + // `/providers/{providerId}/{conceptType}` now exists only as the parent of + // the `stage-for-production` action route. The S3-backed concept routes + // (create/list/get/delete) live under `/staged/...` instead. const providersConceptTypeResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceProvidersProviderIdVarConceptTypeVar', { parentId: providerIdResource.ref, pathPart: '{conceptType}', @@ -128,19 +132,31 @@ export class MmtApiResources extends Construct { }) this.providersConceptTypeResource = providersConceptTypeResource - const providersConceptTypeNativeIdResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceProvidersProviderIdVarConceptTypeVarNativeIdVar', { + const providersConceptTypeStageForProductionResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceProvidersProviderIdVarConceptTypeVarStageForProduction', { parentId: providersConceptTypeResource.ref, - pathPart: '{nativeId}', + pathPart: 'stage-for-production', restApiId: apiGatewayRestApi.ref }) - this.providersConceptTypeNativeIdResource = providersConceptTypeNativeIdResource + this.providersConceptTypeStageForProductionResource = providersConceptTypeStageForProductionResource - const providersConceptTypeNativeIdStageForProductionResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceProvidersProviderIdVarConceptTypeVarNativeIdVarStageForProduction', { - parentId: providersConceptTypeNativeIdResource.ref, - pathPart: 'stage-for-production', + // Staged concepts are opaque promotion artifacts keyed by a generated + // `recordId`; they carry no provider/native identity, so they route under a + // dedicated `/staged/{conceptType}` tree rather than under `/providers`. + const stagedResource = makeRootResource('Staged', 'staged') + + const stagedConceptTypeResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceStagedConceptTypeVar', { + parentId: stagedResource.ref, + pathPart: '{conceptType}', + restApiId: apiGatewayRestApi.ref + }) + this.stagedConceptTypeResource = stagedConceptTypeResource + + const stagedConceptTypeRecordIdResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceStagedConceptTypeVarRecordIdVar', { + parentId: stagedConceptTypeResource.ref, + pathPart: '{recordId}', restApiId: apiGatewayRestApi.ref }) - this.providersConceptTypeNativeIdStageForProductionResource = providersConceptTypeNativeIdStageForProductionResource + this.stagedConceptTypeRecordIdResource = stagedConceptTypeRecordIdResource const templatesResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceTemplates', { parentId: apiGatewayRestApi.attrRootResourceId, @@ -165,14 +181,14 @@ export class MmtApiResources extends Construct { addOptions('Templates', templatesResource, ['GET']) - addOptions('ProvidersProviderIdVarConceptTypeVar', providersConceptTypeResource, ['GET']) + addOptions('ProvidersProviderIdVarConceptTypeVarStageForProduction', providersConceptTypeStageForProductionResource, ['POST']) // PUT (createOrUpdateConcept) is deliberately omitted: it is a // machine-to-machine route behind `stagingApiKeyAuthorizer`, called only by // the UAT forwarding Lambda (server-to-server, no CORS preflight). Leaving // PUT out of the CORS allow-list makes a browser preflight for it fail. - addOptions('ProvidersProviderIdVarConceptTypeVarNativeIdVar', providersConceptTypeNativeIdResource, ['GET', 'DELETE']) + addOptions('StagedConceptTypeVar', stagedConceptTypeResource, ['GET']) - addOptions('ProvidersProviderIdVarConceptTypeVarNativeIdVarStageForProduction', providersConceptTypeNativeIdStageForProductionResource, ['POST']) + addOptions('StagedConceptTypeVarRecordIdVar', stagedConceptTypeRecordIdResource, ['GET', 'DELETE']) } } diff --git a/scripts/localStagingConceptsTesting/deleteConcept.sh b/scripts/localStagingConceptsTesting/deleteConcept.sh index 5595d2ede..a9cb516c6 100644 --- a/scripts/localStagingConceptsTesting/deleteConcept.sh +++ b/scripts/localStagingConceptsTesting/deleteConcept.sh @@ -4,10 +4,10 @@ # (serverless-offline). # # Route: -# DELETE {BASE_URL}/dev/providers/:providerId/:conceptType/:nativeId +# DELETE {BASE_URL}/dev/staged/:conceptType/:recordId # -# This route is EDL-authenticated (real browser user) and runs a per-user -# `fetchProviders` provider-permission check in the handler. It does NOT use +# This route is EDL-authenticated (real browser user). Staged concepts have no +# provider dimension, so there is no per-user provider check. It does NOT use # the Staging-Api-Key. The seed step below uses PUT createOrUpdateConcept, # which IS the machine-to-machine route and still needs the Staging-Api-Key. # @@ -15,13 +15,13 @@ # (STAGE_NAME, API_BASE_URL, STAGING_API_KEY, etc). Override any of these by # exporting them yourself before running this script. # -# This script seeds its own throwaway concept (NATIVE_ID below) via PUT -# before testing delete, so it doesn't consume/remove data seeded by -# postConcepts.sh (e.g. TestCollection1/2/3) that other scripts may rely on. +# This script seeds its own throwaway concept via PUT (capturing the generated +# recordId) before testing delete, so it doesn't consume data other scripts +# may rely on. # # Usage: # ./deleteConcept.sh -# PROVIDER_ID=MMT_2 CONCEPT_TYPE=collections ./deleteConcept.sh +# CONCEPT_TYPE=collections ./deleteConcept.sh set -u @@ -35,30 +35,16 @@ fi BASE_URL="${BASE_URL:-${API_BASE_URL:-http://localhost:4001}}" STAGE="${STAGE:-${STAGE_NAME:-dev}}" STAGING_API_KEY="${STAGING_API_KEY:-local-staging-api-key}" -# 'Bearer ABC-1' is a special test-mode token hardcoded in fetchProviders.js -# that grants access to MMT_1 and MMT_2 without needing real EDL/JWT auth. AUTH_TOKEN="${AUTH_TOKEN:-Bearer ABC-1}" -PROVIDER_ID="${PROVIDER_ID:-MMT_1}" CONCEPT_TYPE="${CONCEPT_TYPE:-collections}" -NATIVE_ID="${NATIVE_ID:-TestDeleteMe}" PASS_COUNT=0 FAIL_COUNT=0 -# Seeds the throwaway concept used by the delete tests below via the -# machine-to-machine PUT route (Staging-Api-Key required). +# Seeds a throwaway concept via the machine-to-machine PUT route and echoes the +# generated recordId. seed_concept() { - local url="${BASE_URL}/${STAGE}/providers/${PROVIDER_ID}/${CONCEPT_TYPE}/${NATIVE_ID}" - local body - body=$(cat </dev/null \ + || echo " (response was not a JSON array)" + +run_test "invalid conceptType" "400" "invalid-type" echo echo "== Results: $PASS_COUNT passed, $FAIL_COUNT failed ==" diff --git a/scripts/localStagingConceptsTesting/postConcepts.sh b/scripts/localStagingConceptsTesting/postConcepts.sh index cfd6918bb..1738ffea7 100644 --- a/scripts/localStagingConceptsTesting/postConcepts.sh +++ b/scripts/localStagingConceptsTesting/postConcepts.sh @@ -2,19 +2,20 @@ # Seeds sample concepts into local S3 by calling the createOrUpdateConcept # endpoint directly, so there's data available for getConcepts.sh, getConcept.sh -# and deleteConcept.sh to list/retrieve/delete. getConcepts.sh in particular -# needs several records to verify sort order; deleteConcept.sh seeds its own -# throwaway record and does not depend on this script. +# and deleteConcept.sh to list/retrieve/delete. # # Route (confirmed from local server startup log): -# PUT {BASE_URL}/dev/providers/:providerId/:conceptType/:nativeId +# PUT {BASE_URL}/dev/staged/:conceptType +# +# A staged concept has no caller-supplied identity: createOrUpdateConcept +# generates a `recordId` (UUID) and returns `{ conceptType, recordId }`. This +# script prints each generated recordId so you can feed one to getConcept.sh / +# deleteConcept.sh. # # This is a direct exercise of the machine-to-machine PUT route, authenticated # with the Staging-Api-Key header. It is NOT stageForProduction.sh: that script # POSTs to /stage-for-production, which runs the UAT forwarding Lambda (EDL auth -# + a server-side call back to this same PUT route). Both end up writing to local -# S3, but use this one when you just want fixture data without involving the -# forwarding Lambda or the PRODUCTION_* config. +# + a server-side call back to this same PUT route). # # Sources local-env.sh (if present) for shared local dev config # (STAGE_NAME, API_BASE_URL, STAGING_API_KEY, etc). Override any of these by @@ -22,15 +23,15 @@ # # Usage: # ./postConcepts.sh -# Seeds the default sample concepts (TestCollection1/2/3) defined below. +# Seeds three sample concepts and prints their recordIds. # -# ./postConcepts.sh -# Seeds a single concept using and the JSON body read from -# , e.g.: +# ./postConcepts.sh