From 903c26c3e19ebde9ca7734efef2171399d0583fc Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Mon, 23 Mar 2026 11:02:18 +0000 Subject: [PATCH 01/55] Return subscription and target ids from tranform lambda --- .../src/__tests__/index.component.test.ts | 11 +- .../src/__tests__/index.test.ts | 133 +++++++++++++++-- .../services/subscription-filter.test.ts | 12 +- .../src/handler.ts | 135 +++++++++++------- .../src/services/subscription-filter.ts | 65 +++++---- 5 files changed, 268 insertions(+), 88 deletions(-) diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts index c524ef3c..b46c49f8 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.component.test.ts @@ -150,7 +150,10 @@ describe("Lambda handler with S3 subscription filtering", () => { expect(mockSend.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); expect(mockSsmSend).toHaveBeenCalledTimes(1); expect(mockSsmSend.mock.calls[0][0]).toBeInstanceOf(GetParameterCommand); - expect(result[0].headers["x-hmac-sha256-signature"]).toMatch(/^[0-9a-f]+$/); + expect(result[0]).toHaveProperty("payload"); + expect(result[0]).toHaveProperty("subscriptions"); + expect(result[0]).toHaveProperty("signatures"); + expect(Object.values(result[0].signatures)[0]).toMatch(/^[0-9a-f]+$/); }); it("filters out event when status is not in subscription", async () => { @@ -200,8 +203,10 @@ describe("Lambda handler with S3 subscription filtering", () => { // Only the DELIVERED event passes the filter expect(result).toHaveLength(1); - expect((result[0].data as { messageStatus: string }).messageStatus).toBe( - "DELIVERED", + expect(result[0].payload.data[0].attributes).toEqual( + expect.objectContaining({ + messageStatus: "delivered", + }), ); }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts index 897e728f..3b9123be 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -171,8 +171,10 @@ describe("Lambda handler", () => { const result = await handler([sqsMessage]); expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty("transformedPayload"); - const dataItem = result[0].transformedPayload.data[0]; + expect(result[0]).toHaveProperty("payload"); + expect(result[0]).toHaveProperty("subscriptions"); + expect(result[0]).toHaveProperty("signatures"); + const dataItem = result[0].payload.data[0]; expect(dataItem.type).toBe("MessageStatus"); expect((dataItem.attributes as MessageStatusAttributes).messageStatus).toBe( "delivered", @@ -232,6 +234,119 @@ describe("Lambda handler", () => { ); }); + it("should skip targets without apiKey and still deliver if at least one valid target exists", async () => { + const customConfigLoader = { + loadClientConfig: jest.fn().mockResolvedValue( + createClientSubscriptionConfig("client-abc-123", { + subscriptions: [ + createMessageStatusSubscription(["DELIVERED"], { + targetIds: ["target-no-key", DEFAULT_TARGET_ID], + }), + ], + targets: [ + createTarget({ + targetId: "target-no-key", + apiKey: undefined as unknown as { + headerName: string; + headerValue: string; + }, + }), + createTarget({ + targetId: DEFAULT_TARGET_ID, + apiKey: { + headerName: "x-api-key", + headerValue: "valid-key", + }, + }), + ], + }), + ), + } as unknown as ConfigLoader; + + const handlerWithMixedTargets = createHandler({ + createObservabilityService: () => + new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger), + createConfigLoaderService: () => + ({ getLoader: () => customConfigLoader }) as ConfigLoaderService, + createApplicationsMapService: makeStubApplicationsMapService, + }); + + const sqsMessage: SQSRecord = { + messageId: "sqs-msg-id-mixed", + receiptHandle: "receipt-handle-mixed", + body: JSON.stringify(validMessageStatusEvent), + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: "1519211230", + SenderId: "ABCDEFGHIJ", + ApproximateFirstReceiveTimestamp: "1519211230", + }, + messageAttributes: {}, + md5OfBody: "mock-md5", + eventSource: "aws:sqs", + eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:mock-queue", + awsRegion: "eu-west-2", + }; + + const result = await handlerWithMixedTargets([sqsMessage]); + + expect(result).toHaveLength(1); + expect(result[0].signatures).not.toHaveProperty("target-no-key"); + expect(result[0].signatures).toHaveProperty(DEFAULT_TARGET_ID); + }); + + it("should filter out event when no targets have valid apiKeys", async () => { + const customConfigLoader = { + loadClientConfig: jest.fn().mockResolvedValue( + createClientSubscriptionConfig("client-abc-123", { + subscriptions: [ + createMessageStatusSubscription(["DELIVERED"], { + targetIds: ["target-no-key"], + }), + ], + targets: [ + createTarget({ + targetId: "target-no-key", + apiKey: undefined as unknown as { + headerName: string; + headerValue: string; + }, + }), + ], + }), + ), + } as unknown as ConfigLoader; + + const handlerNoKeys = createHandler({ + createObservabilityService: () => + new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger), + createConfigLoaderService: () => + ({ getLoader: () => customConfigLoader }) as ConfigLoaderService, + createApplicationsMapService: makeStubApplicationsMapService, + }); + + const sqsMessage: SQSRecord = { + messageId: "sqs-msg-id-nokey", + receiptHandle: "receipt-handle-nokey", + body: JSON.stringify(validMessageStatusEvent), + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: "1519211230", + SenderId: "ABCDEFGHIJ", + ApproximateFirstReceiveTimestamp: "1519211230", + }, + messageAttributes: {}, + md5OfBody: "mock-md5", + eventSource: "aws:sqs", + eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:mock-queue", + awsRegion: "eu-west-2", + }; + + const result = await handlerNoKeys([sqsMessage]); + + expect(result).toHaveLength(0); + }); + it("should handle batch of SQS messages from EventBridge Pipes", async () => { const sqsMessages: SQSRecord[] = [ { @@ -271,8 +386,8 @@ describe("Lambda handler", () => { const result = await handler(sqsMessages); expect(result).toHaveLength(2); - expect(result[0]).toHaveProperty("transformedPayload"); - expect(result[1]).toHaveProperty("transformedPayload"); + expect(result[0]).toHaveProperty("payload"); + expect(result[1]).toHaveProperty("payload"); }); it("should reject event with unsupported type before reaching transformer", async () => { @@ -351,8 +466,10 @@ describe("Lambda handler", () => { const result = await handler([sqsMessage]); expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty("transformedPayload"); - const dataItem = result[0].transformedPayload.data[0]; + expect(result[0]).toHaveProperty("payload"); + expect(result[0]).toHaveProperty("subscriptions"); + expect(result[0]).toHaveProperty("signatures"); + const dataItem = result[0].payload.data[0]; expect(dataItem.type).toBe("ChannelStatus"); expect((dataItem.attributes as ChannelStatusAttributes).channelStatus).toBe( "delivered", @@ -513,8 +630,8 @@ describe("Lambda handler", () => { const result = await handler(sqsMessages); expect(result).toHaveLength(2); - expect(result[0].transformedPayload.data[0].type).toBe("MessageStatus"); - expect(result[1].transformedPayload.data[0].type).toBe("ChannelStatus"); + expect(result[0].payload.data[0].type).toBe("MessageStatus"); + expect(result[1].payload.data[0].type).toBe("ChannelStatus"); }); }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts index c302fda1..153ab934 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/subscription-filter.test.ts @@ -113,6 +113,7 @@ describe("evaluateSubscriptionFilters", () => { matched: true, subscriptionType: "MessageStatus", targetIds: ["00000000-0000-4000-8000-000000000001"], + subscriptionIds: ["00000000-0000-0000-0000-000000000001"], }); }); @@ -128,14 +129,16 @@ describe("evaluateSubscriptionFilters", () => { }); }); - it("returns only matched subscription target IDs", () => { + it("returns only matched subscription target IDs and subscription IDs", () => { const event = createMessageStatusEvent("client-1", "DELIVERED"); const config = createClientSubscriptionConfig("client-1", { subscriptions: [ createMessageStatusSubscription(["DELIVERED"], { + subscriptionId: "sub-a", targetIds: ["target-a"], }), createMessageStatusSubscription(["FAILED"], { + subscriptionId: "sub-b", targetIds: ["target-b"], }), ], @@ -147,6 +150,7 @@ describe("evaluateSubscriptionFilters", () => { matched: true, subscriptionType: "MessageStatus", targetIds: ["target-a"], + subscriptionIds: ["sub-a"], }); }); }); @@ -174,6 +178,7 @@ describe("evaluateSubscriptionFilters", () => { matched: true, subscriptionType: "ChannelStatus", targetIds: ["00000000-0000-4000-8000-000000000001"], + subscriptionIds: ["00000000-0000-0000-0000-000000000002"], }); }); @@ -201,7 +206,7 @@ describe("evaluateSubscriptionFilters", () => { }); }); - it("returns only matched channel subscription target IDs", () => { + it("returns only matched channel subscription target IDs and subscription IDs", () => { const event = createChannelStatusEvent( "client-1", "SMS", @@ -217,6 +222,7 @@ describe("evaluateSubscriptionFilters", () => { ["delivered"], "EMAIL", { + subscriptionId: "sub-email", targetIds: ["target-email"], }, ), @@ -225,6 +231,7 @@ describe("evaluateSubscriptionFilters", () => { ["permanent_failure"], "SMS", { + subscriptionId: "sub-sms", targetIds: ["target-sms"], }, ), @@ -237,6 +244,7 @@ describe("evaluateSubscriptionFilters", () => { matched: true, subscriptionType: "ChannelStatus", targetIds: ["target-sms"], + subscriptionIds: ["sub-sms"], }); }); }); diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts index 4c7ae933..32ec4c71 100644 --- a/lambdas/client-transform-filter-lambda/src/handler.ts +++ b/lambdas/client-transform-filter-lambda/src/handler.ts @@ -22,9 +22,25 @@ type UnsignedEvent = StatusPublishEvent & { transformedPayload: ClientCallbackPayload; }; -export interface TransformedEvent extends StatusPublishEvent { - transformedPayload: ClientCallbackPayload; - headers: { "x-hmac-sha256-signature": string }; +type FilteredEvent = UnsignedEvent & { + subscriptionIds: string[]; + targetIds: string[]; +}; + +type SignedEvent = { + transformedEvent: TransformedEvent; + deliveryContext: { + correlationId: string; + eventType: string; + clientId: string; + messageId: string; + }; +}; + +export interface TransformedEvent { + payload: ClientCallbackPayload; + subscriptions: string[]; + signatures: Record; } class BatchStats { @@ -125,17 +141,17 @@ function processSingleEvent( type ClientConfigMap = Map; async function signBatch( - filteredEvents: UnsignedEvent[], + filteredEvents: FilteredEvent[], applicationsMapService: ApplicationsMapService, configByClientId: ClientConfigMap, stats: BatchStats, observability: ObservabilityService, -): Promise { +): Promise { const results = await pMap( filteredEvents, - async (event): Promise => { + async (event): Promise => { const { clientId } = event.data; - const correlationId = extractCorrelationId(event); + const correlationId = extractCorrelationId(event) ?? event.id; const applicationId = await applicationsMapService.getApplicationId(clientId); @@ -149,53 +165,64 @@ async function signBatch( } const clientConfig = configByClientId.get(clientId); - const apiKey = clientConfig?.targets?.[0]?.apiKey?.headerValue; - if (!apiKey) { + const targetsById = new Map( + (clientConfig?.targets ?? []).map((t) => [t.targetId, t]), + ); + + const signaturesByTarget = new Map(); + let hasValidTarget = false; + + for (const targetId of event.targetIds) { + const target = targetsById.get(targetId); + const apiKey = target?.apiKey?.headerValue; + if (apiKey) { + const signature = signPayload( + event.transformedPayload, + applicationId, + apiKey, + ); + signaturesByTarget.set(targetId, signature); + observability.recordCallbackSigned( + event.transformedPayload, + correlationId, + clientId, + signature, + ); + hasValidTarget = true; + } else { + logger.warn( + "No apiKey for target - target will be skipped in signatures", + { clientId, correlationId, targetId }, + ); + } + } + + if (!hasValidTarget) { stats.recordFiltered(); logger.warn( - "No apiKey in client config - event will not be delivered", + "No valid targets with apiKey - event will not be delivered", { clientId, correlationId }, ); return undefined; } - const signature = signPayload( - event.transformedPayload, - applicationId, - apiKey, - ); - const signedEvent: TransformedEvent = { - ...event, - headers: { "x-hmac-sha256-signature": signature }, + return { + transformedEvent: { + payload: event.transformedPayload, + subscriptions: event.subscriptionIds, + signatures: Object.fromEntries(signaturesByTarget), + }, + deliveryContext: { + correlationId, + eventType: event.type, + clientId, + messageId: event.data.messageId, + }, }; - observability.recordCallbackSigned( - signedEvent.transformedPayload, - correlationId, - clientId, - signature, - ); - return signedEvent; }, { concurrency: BATCH_CONCURRENCY }, ); - return results.filter((e): e is TransformedEvent => e !== undefined); -} - -function recordDeliveryInitiated( - transformedEvents: TransformedEvent[], - observability: ObservabilityService, -): void { - for (const transformedEvent of transformedEvents) { - const { clientId, messageId } = transformedEvent.data; - const correlationId = extractCorrelationId(transformedEvent); - - observability.recordDeliveryInitiated({ - correlationId, - eventType: transformedEvent.type, - clientId, - messageId, - }); - } + return results.filter((e): e is SignedEvent => e !== undefined); } async function loadClientConfigs( @@ -219,10 +246,10 @@ async function filterBatch( configByClientId: ClientConfigMap, observability: ObservabilityService, stats: BatchStats, -): Promise { +): Promise { observability.recordFilteringStarted({ batchSize: transformedEvents.length }); - const filtered: UnsignedEvent[] = []; + const filtered: FilteredEvent[] = []; for (const event of transformedEvents) { const { clientId } = event.data; @@ -231,7 +258,11 @@ async function filterBatch( const filterResult = evaluateSubscriptionFilters(event, config); if (filterResult.matched) { - filtered.push(event); + filtered.push({ + ...event, + subscriptionIds: filterResult.subscriptionIds ?? [], + targetIds: filterResult.targetIds ?? [], + }); observability.recordFilteringMatched({ correlationId, clientId, @@ -313,6 +344,14 @@ export async function processEvents( observability, ); + for (const signedEvent of signedEvents) { + observability.recordDeliveryInitiated(signedEvent.deliveryContext); + } + + const deliverableEvents = signedEvents.map( + (signedEvent) => signedEvent.transformedEvent, + ); + const processingTime = Date.now() - startTime; observability.logBatchProcessingCompleted({ ...stats.toObject(), @@ -320,10 +359,8 @@ export async function processEvents( processingTimeMs: processingTime, }); - recordDeliveryInitiated(signedEvents, observability); - await observability.flush(); - return signedEvents; + return deliverableEvents; } catch (error) { stats.recordFailure(); diff --git a/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts b/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts index 55c131c7..2a51627f 100644 --- a/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts +++ b/lambdas/client-transform-filter-lambda/src/services/subscription-filter.ts @@ -14,6 +14,7 @@ type FilterResult = { matched: boolean; subscriptionType: "MessageStatus" | "ChannelStatus" | "Unknown"; targetIds?: string[]; + subscriptionIds?: string[]; }; const unique = (values: string[]): string[] => [...new Set(values)]; @@ -30,48 +31,60 @@ export const evaluateSubscriptionFilters = ( } if (event.type === EventTypes.MESSAGE_STATUS_PUBLISHED) { - const typedEvent = event as StatusPublishEvent; + const matchingSubscriptions = config.subscriptions.filter((subscription) => + matchesMessageStatusSubscription( + { + ...config, + subscriptions: [subscription], + }, + event as StatusPublishEvent, + ), + ); const matchingTargetIds = unique( - config.subscriptions - .filter((subscription) => - matchesMessageStatusSubscription( - { - ...config, - subscriptions: [subscription], - }, - typedEvent, - ), - ) - .flatMap((subscription) => subscription.targetIds), + matchingSubscriptions.flatMap((subscription) => subscription.targetIds), + ); + const matchingSubscriptionIds = unique( + matchingSubscriptions.map((subscription) => subscription.subscriptionId), ); return { matched: matchingTargetIds.length > 0, subscriptionType: "MessageStatus", - ...(matchingTargetIds.length > 0 ? { targetIds: matchingTargetIds } : {}), + ...(matchingTargetIds.length > 0 + ? { + targetIds: matchingTargetIds, + subscriptionIds: matchingSubscriptionIds, + } + : {}), }; } if (event.type === EventTypes.CHANNEL_STATUS_PUBLISHED) { - const typedEvent = event as StatusPublishEvent; + const matchingSubscriptions = config.subscriptions.filter((subscription) => + matchesChannelStatusSubscription( + { + ...config, + subscriptions: [subscription], + }, + event as StatusPublishEvent, + ), + ); const matchingTargetIds = unique( - config.subscriptions - .filter((subscription) => - matchesChannelStatusSubscription( - { - ...config, - subscriptions: [subscription], - }, - typedEvent, - ), - ) - .flatMap((subscription) => subscription.targetIds), + matchingSubscriptions.flatMap((subscription) => subscription.targetIds), + ); + const matchingSubscriptionIds = unique( + matchingSubscriptions.map((subscription) => subscription.subscriptionId), ); return { matched: matchingTargetIds.length > 0, subscriptionType: "ChannelStatus", - ...(matchingTargetIds.length > 0 ? { targetIds: matchingTargetIds } : {}), + ...(matchingTargetIds.length > 0 + ? { + targetIds: matchingTargetIds, + subscriptionIds: matchingSubscriptionIds, + } + : {}), }; } From 93c047b0882e88a1ddb92617d58464b9a213dab1 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Mon, 23 Mar 2026 11:49:24 +0000 Subject: [PATCH 02/55] Terraform changes --- .../terraform/components/callbacks/locals.tf | 112 ++++++++++++++++-- .../callbacks/module_client_destination.tf | 17 +-- .../components/callbacks/pipes_pipe_main.tf | 6 +- .../modules/client-destination/README.md | 11 +- .../cloudwatch_event_api_destination_this.tf | 16 +-- .../cloudwatch_event_connection_main.tf | 12 +- .../cloudwatch_event_rule_main.tf | 34 ++++-- .../iam_role_api_target_role.tf | 12 +- .../client-destination/module_target_dlq.tf | 11 +- .../modules/client-destination/variables.tf | 55 ++++----- .../terraform/modules/clients/README.md | 19 +++ .../src/__tests__/index.test.ts | 6 +- .../src/handler.ts | 2 +- tests/integration/helpers/sqs.ts | 2 +- tests/integration/jest.global-setup.ts | 18 +-- 15 files changed, 222 insertions(+), 111 deletions(-) create mode 100644 infrastructure/terraform/modules/clients/README.md diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf index b9f7d4d8..7315f34f 100644 --- a/infrastructure/terraform/components/callbacks/locals.tf +++ b/infrastructure/terraform/components/callbacks/locals.tf @@ -4,29 +4,119 @@ locals { root_domain_name = "${var.environment}.${local.acct.route53_zone_names["client-callbacks"]}" # e.g. [main|dev|abxy0].smsnudge.[dev|nonprod|prod].nhsnotify.national.nhs.uk root_domain_id = local.acct.route53_zone_ids["client-callbacks"] - clients_by_name = { - for client in var.clients : - client.connection_name => client - } + clients_dir_path = "${path.module}/../../modules/clients" + + config_files = fileset(local.clients_dir_path, "*.json") + + file_clients = length(local.config_files) > 0 ? merge([ + for filename in local.config_files : { + (replace(filename, ".json", "")) = jsondecode(file("${local.clients_dir_path}/${filename}")) + } + ]...) : {} # Automatic test client when mock webhook is deployed mock_client = var.deploy_mock_webhook ? { "mock-client" = { - connection_name = "mock-client" - destination_name = "test-destination" + clientId = "mock-client" + targets = [ + { + targetId = "mock-target-1" + type = "API" + + invocationEndpoint = aws_lambda_function_url.mock_webhook[0].function_url + invocationMethod = "POST" + invocationRateLimit = 10 + + apiKey = { + headerName = "x-api-key" + headerValue = random_password.mock_webhook_api_key[0].result + } + } + ] + + subscriptions = [ + { + subscriptionId = "mock-subscription-message-status" + subscriptionType = "MessageStatus" + targetIds = ["mock-target-1"] + messageStatuses = ["DELIVERED", "FAILED"] + }, + { + subscriptionId = "mock-subscription-channel-status" + subscriptionType = "ChannelStatus" + targetIds = ["mock-target-1"] + channelType = "NHSAPP" + channelStatuses = ["DELIVERED", "FAILED"] + supplierStatuses = ["delivered", "permanent_failure"] + } + ] + } + } : {} + + all_clients = merge(local.file_clients, local.mock_client) + + file_targets = length(local.file_clients) > 0 ? merge([ + for client_id, data in local.file_clients : { + for target in try(data.targets, []) : target.targetId => { + client_id = client_id + target_id = target.targetId + invocation_endpoint = target.invocationEndpoint + invocation_rate_limit_per_second = target.invocationRateLimit + http_method = target.invocationMethod + header_name = target.apiKey.headerName + header_value = target.apiKey.headerValue + } + } + ]...) : {} + + mock_targets = var.deploy_mock_webhook ? { + "mock-target-1" = { + client_id = "mock-client" + target_id = "mock-target-1" invocation_endpoint = aws_lambda_function_url.mock_webhook[0].function_url invocation_rate_limit_per_second = 10 http_method = "POST" header_name = "x-api-key" header_value = random_password.mock_webhook_api_key[0].result - client_detail = [ - "uk.nhs.notify.message.status.PUBLISHED.v1", - "uk.nhs.notify.channel.status.PUBLISHED.v1" - ] } } : {} - all_clients = merge(local.clients_by_name, local.mock_client) + all_targets = merge(local.file_targets, local.mock_targets) + + file_subscriptions = length(local.file_clients) > 0 ? merge([ + for client_id, data in local.file_clients : { + for subscription in try(data.subscriptions, []) : subscription.subscriptionId => { + client_id = client_id + subscription_id = subscription.subscriptionId + target_ids = try(subscription.targetIds, []) + } + } + ]...) : {} + + mock_subscriptions = var.deploy_mock_webhook ? { + "mock-subscription-message-status" = { + client_id = "mock-client" + subscription_id = "mock-subscription-message-status" + target_ids = ["mock-target-1"] + } + "mock-subscription-channel-status" = { + client_id = "mock-client" + subscription_id = "mock-subscription-channel-status" + target_ids = ["mock-target-1"] + } + } : {} + + all_subscriptions = merge(local.file_subscriptions, local.mock_subscriptions) + + subscription_targets = length(local.all_subscriptions) > 0 ? merge([ + for subscription_id, subscription in local.all_subscriptions : { + for target_id in subscription.target_ids : + "${subscription_id}-${target_id}" => { + subscription_id = subscription_id + target_id = target_id + } + } + ]...) : {} applications_map_parameter_name = coalesce(var.applications_map_parameter_name, "/${var.project}/${var.environment}/${var.component}/applications-map") } diff --git a/infrastructure/terraform/components/callbacks/module_client_destination.tf b/infrastructure/terraform/components/callbacks/module_client_destination.tf index 19f3c12f..b3170e21 100644 --- a/infrastructure/terraform/components/callbacks/module_client_destination.tf +++ b/infrastructure/terraform/components/callbacks/module_client_destination.tf @@ -1,6 +1,5 @@ module "client_destination" { - source = "../../modules/client-destination" - for_each = local.all_clients + source = "../../modules/client-destination" project = var.project aws_account_id = var.aws_account_id @@ -11,16 +10,8 @@ module "client_destination" { kms_key_arn = module.kms.key_arn - connection_name = each.value.connection_name - destination_name = each.value.destination_name - invocation_endpoint = each.value.invocation_endpoint - invocation_rate_limit_per_second = each.value.invocation_rate_limit_per_second - http_method = each.value.http_method - header_name = each.value.header_name - header_value = each.value.header_value - client_detail = each.value.client_detail - - - + targets = local.all_targets + subscriptions = local.all_subscriptions + subscription_targets = local.subscription_targets } diff --git a/infrastructure/terraform/components/callbacks/pipes_pipe_main.tf b/infrastructure/terraform/components/callbacks/pipes_pipe_main.tf index 6c088133..3fddfcca 100644 --- a/infrastructure/terraform/components/callbacks/pipes_pipe_main.tf +++ b/infrastructure/terraform/components/callbacks/pipes_pipe_main.tf @@ -25,9 +25,9 @@ resource "aws_pipes_pipe" "main" { input_template = <, - "transformedPayload": <$.transformedPayload>, - "headers": <$.headers> + "payload": <$.payload>, + "subscriptions": <$.subscriptions>, + "signatures": <$.signatures> } EOF } diff --git a/infrastructure/terraform/modules/client-destination/README.md b/infrastructure/terraform/modules/client-destination/README.md index 1cbd4706..31dd9fac 100644 --- a/infrastructure/terraform/modules/client-destination/README.md +++ b/infrastructure/terraform/modules/client-destination/README.md @@ -11,19 +11,14 @@ No requirements. |------|-------------|------|---------|:--------:| | [aws\_account\_id](#input\_aws\_account\_id) | Account ID | `string` | n/a | yes | | [client\_bus\_name](#input\_client\_bus\_name) | EventBus name where you create the rule | `string` | n/a | yes | -| [client\_detail](#input\_client\_detail) | Client Event Detail | `list(string)` | n/a | yes | | [component](#input\_component) | Component name | `string` | n/a | yes | -| [connection\_name](#input\_connection\_name) | Connection name | `string` | n/a | yes | -| [destination\_name](#input\_destination\_name) | Destination Name | `string` | n/a | yes | | [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes | -| [header\_name](#input\_header\_name) | Header name | `string` | n/a | yes | -| [header\_value](#input\_header\_value) | Header value | `string` | n/a | yes | -| [http\_method](#input\_http\_method) | HTTP Method | `string` | n/a | yes | -| [invocation\_endpoint](#input\_invocation\_endpoint) | Invocation Endpoint | `string` | n/a | yes | -| [invocation\_rate\_limit\_per\_second](#input\_invocation\_rate\_limit\_per\_second) | Invocation Rate Limit Per Second | `string` | n/a | yes | | [kms\_key\_arn](#input\_kms\_key\_arn) | KMS Key ARN | `string` | n/a | yes | | [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | | [region](#input\_region) | AWS Region | `string` | n/a | yes | +| [subscription\_targets](#input\_subscription\_targets) | Flattened subscription-target fanout map keyed by subscription-target composite key |
map(object({
subscription_id = string
target_id = string
}))
| n/a | yes | +| [subscriptions](#input\_subscriptions) | Flattened subscription definitions keyed by subscription\_id |
map(object({
client_id = string
subscription_id = string
target_ids = list(string)
}))
| n/a | yes | +| [targets](#input\_targets) | Flattened target definitions keyed by target\_id |
map(object({
client_id = string
target_id = string
invocation_endpoint = string
invocation_rate_limit_per_second = number
http_method = string
header_name = string
header_value = string
}))
| n/a | yes | ## Modules | Name | Source | Version | diff --git a/infrastructure/terraform/modules/client-destination/cloudwatch_event_api_destination_this.tf b/infrastructure/terraform/modules/client-destination/cloudwatch_event_api_destination_this.tf index 53499d92..4bec92cc 100644 --- a/infrastructure/terraform/modules/client-destination/cloudwatch_event_api_destination_this.tf +++ b/infrastructure/terraform/modules/client-destination/cloudwatch_event_api_destination_this.tf @@ -1,8 +1,10 @@ -resource "aws_cloudwatch_event_api_destination" "main" { - name = "${local.csi}-${var.destination_name}" - description = "API Destination for ${var.destination_name}" - invocation_endpoint = var.invocation_endpoint - http_method = var.http_method - invocation_rate_limit_per_second = var.invocation_rate_limit_per_second - connection_arn = aws_cloudwatch_event_connection.main.arn +resource "aws_cloudwatch_event_api_destination" "per_target" { + for_each = var.targets + + name = "${local.csi}-${each.key}" + description = "API Destination for ${each.key}" + invocation_endpoint = each.value.invocation_endpoint + http_method = each.value.http_method + invocation_rate_limit_per_second = each.value.invocation_rate_limit_per_second + connection_arn = aws_cloudwatch_event_connection.per_target[each.key].arn } diff --git a/infrastructure/terraform/modules/client-destination/cloudwatch_event_connection_main.tf b/infrastructure/terraform/modules/client-destination/cloudwatch_event_connection_main.tf index 7136d70b..7546d666 100644 --- a/infrastructure/terraform/modules/client-destination/cloudwatch_event_connection_main.tf +++ b/infrastructure/terraform/modules/client-destination/cloudwatch_event_connection_main.tf @@ -1,12 +1,14 @@ -resource "aws_cloudwatch_event_connection" "main" { - name = "${local.csi}-${var.connection_name}" - description = "Event Connection which would be used by API Destination ${var.connection_name}" +resource "aws_cloudwatch_event_connection" "per_target" { + for_each = var.targets + + name = "${local.csi}-${each.key}" + description = "Event Connection which would be used by API Destination ${each.key}" authorization_type = "API_KEY" auth_parameters { api_key { - key = var.header_name - value = var.header_value + key = each.value.header_name + value = each.value.header_value } } } diff --git a/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf b/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf index 4bce1003..2586116e 100644 --- a/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf +++ b/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf @@ -1,30 +1,42 @@ -resource "aws_cloudwatch_event_rule" "main" { - name = "${local.csi}-${var.connection_name}" - description = "Client Callbacks event rule for inbound events" +resource "aws_cloudwatch_event_rule" "per_subscription" { + for_each = var.subscriptions + + name = "${local.csi}-${each.key}" + description = "Client Callbacks event rule for subscription ${each.key}" event_bus_name = var.client_bus_name event_pattern = jsonencode({ "detail" : { - "type" : var.client_detail + "subscriptions" : [each.value.subscription_id] } }) } -resource "aws_cloudwatch_event_target" "main" { - rule = aws_cloudwatch_event_rule.main.name - arn = aws_cloudwatch_event_api_destination.main.arn - target_id = "${local.csi}-${var.connection_name}" +resource "aws_cloudwatch_event_target" "per_subscription_target" { + for_each = var.subscription_targets + + rule = aws_cloudwatch_event_rule.per_subscription[each.value.subscription_id].name + arn = aws_cloudwatch_event_api_destination.per_target[each.value.target_id].arn + target_id = "${local.csi}-${each.value.target_id}" role_arn = aws_iam_role.api_target_role.arn event_bus_name = var.client_bus_name - input_path = "$.detail.transformedPayload" dead_letter_config { - arn = module.target_dlq.sqs_queue_arn + arn = module.target_dlq[each.value.target_id].sqs_queue_arn + } + + input_transformer { + input_paths = { + signature = "$.detail.signatures.${replace(each.value.target_id, "-", "_")}" + payload = "$.detail.payload" + } + + input_template = "" } http_target { header_parameters = { - "x-hmac-sha256-signature" = "$.detail.headers.x-hmac-sha256-signature" + "x-hmac-sha256-signature" = "$.detail.signatures.${replace(each.value.target_id, "-", "_")}" } } diff --git a/infrastructure/terraform/modules/client-destination/iam_role_api_target_role.tf b/infrastructure/terraform/modules/client-destination/iam_role_api_target_role.tf index 92c16aaa..bcab3490 100644 --- a/infrastructure/terraform/modules/client-destination/iam_role_api_target_role.tf +++ b/infrastructure/terraform/modules/client-destination/iam_role_api_target_role.tf @@ -31,7 +31,7 @@ resource "aws_iam_policy" "api_target_role" { data "aws_iam_policy_document" "api_target_role" { statement { - sid = replace("AllowAPIDestinationAccessFor${var.connection_name}", "-", "") + sid = "AllowAPIDestinationAccess" effect = "Allow" actions = [ @@ -39,12 +39,13 @@ data "aws_iam_policy_document" "api_target_role" { ] resources = [ - aws_cloudwatch_event_api_destination.main.arn + for destination in aws_cloudwatch_event_api_destination.per_target : + destination.arn ] } statement { - sid = replace("AllowSQSSendMessageForDLQFor${var.connection_name}", "-", "") + sid = "AllowSQSSendMessageForDLQ" effect = "Allow" actions = [ @@ -52,12 +53,13 @@ data "aws_iam_policy_document" "api_target_role" { ] resources = [ - module.target_dlq.sqs_queue_arn, + for dlq in module.target_dlq : + dlq.sqs_queue_arn ] } statement { - sid = replace("AllowKMSForDLQFor${var.connection_name}", "-", "") + sid = "AllowKMSForDLQ" effect = "Allow" actions = [ diff --git a/infrastructure/terraform/modules/client-destination/module_target_dlq.tf b/infrastructure/terraform/modules/client-destination/module_target_dlq.tf index 5a1457e5..3e2cd83b 100644 --- a/infrastructure/terraform/modules/client-destination/module_target_dlq.tf +++ b/infrastructure/terraform/modules/client-destination/module_target_dlq.tf @@ -1,12 +1,13 @@ module "target_dlq" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip" + for_each = var.targets aws_account_id = var.aws_account_id component = var.component environment = var.environment project = var.project region = var.region - name = "${var.connection_name}-dlq" + name = "${each.key}-dlq" sqs_kms_key_arn = var.kms_key_arn @@ -14,10 +15,12 @@ module "target_dlq" { create_dlq = false - sqs_policy_overload = data.aws_iam_policy_document.target_dlq.json + sqs_policy_overload = data.aws_iam_policy_document.target_dlq[each.key].json } data "aws_iam_policy_document" "target_dlq" { + for_each = var.targets + statement { sid = "AllowEventBridgeToSendMessage" effect = "Allow" @@ -32,7 +35,7 @@ data "aws_iam_policy_document" "target_dlq" { ] resources = [ - "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${var.component}-${var.connection_name}-dlq-queue" + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${var.component}-${each.key}-dlq-queue" ] } } diff --git a/infrastructure/terraform/modules/client-destination/variables.tf b/infrastructure/terraform/modules/client-destination/variables.tf index a5360104..2b9a0ceb 100644 --- a/infrastructure/terraform/modules/client-destination/variables.tf +++ b/infrastructure/terraform/modules/client-destination/variables.tf @@ -23,44 +23,37 @@ variable "region" { description = "AWS Region" } -variable "connection_name" { - type = string - description = "Connection name" -} - -variable "header_name" { - type = string - description = "Header name" -} - -variable "header_value" { - type = string - description = "Header value" -} +variable "targets" { + type = map(object({ + client_id = string + target_id = string + invocation_endpoint = string + invocation_rate_limit_per_second = number + http_method = string + header_name = string + header_value = string + })) -variable "destination_name" { - type = string - description = "Destination Name" + description = "Flattened target definitions keyed by target_id" } -variable "invocation_endpoint" { - type = string - description = "Invocation Endpoint" -} +variable "subscriptions" { + type = map(object({ + client_id = string + subscription_id = string + target_ids = list(string) + })) -variable "invocation_rate_limit_per_second" { - type = string - description = "Invocation Rate Limit Per Second" + description = "Flattened subscription definitions keyed by subscription_id" } -variable "http_method" { - type = string - description = "HTTP Method" -} +variable "subscription_targets" { + type = map(object({ + subscription_id = string + target_id = string + })) -variable "client_detail" { - type = list(string) - description = "Client Event Detail" + description = "Flattened subscription-target fanout map keyed by subscription-target composite key" } variable "client_bus_name" { diff --git a/infrastructure/terraform/modules/clients/README.md b/infrastructure/terraform/modules/clients/README.md new file mode 100644 index 00000000..df8c1f5c --- /dev/null +++ b/infrastructure/terraform/modules/clients/README.md @@ -0,0 +1,19 @@ + + + + +## Requirements + +No requirements. +## Inputs + +No inputs. +## Modules + +No modules. +## Outputs + +No outputs. + + + diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts index 3b9123be..2006e5bf 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -291,8 +291,10 @@ describe("Lambda handler", () => { const result = await handlerWithMixedTargets([sqsMessage]); expect(result).toHaveLength(1); - expect(result[0].signatures).not.toHaveProperty("target-no-key"); - expect(result[0].signatures).toHaveProperty(DEFAULT_TARGET_ID); + expect(result[0].signatures).not.toHaveProperty("target_no_key"); + expect(result[0].signatures).toHaveProperty( + DEFAULT_TARGET_ID.replaceAll("-", "_"), + ); }); it("should filter out event when no targets have valid apiKeys", async () => { diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts index 32ec4c71..fa4d3c4c 100644 --- a/lambdas/client-transform-filter-lambda/src/handler.ts +++ b/lambdas/client-transform-filter-lambda/src/handler.ts @@ -181,7 +181,7 @@ async function signBatch( applicationId, apiKey, ); - signaturesByTarget.set(targetId, signature); + signaturesByTarget.set(targetId.replaceAll("-", "_"), signature); observability.recordCallbackSigned( event.transformedPayload, correlationId, diff --git a/tests/integration/helpers/sqs.ts b/tests/integration/helpers/sqs.ts index 49f70e78..858923e4 100644 --- a/tests/integration/helpers/sqs.ts +++ b/tests/integration/helpers/sqs.ts @@ -62,7 +62,7 @@ export function buildInboundEventDlqQueueUrl( export function buildMockClientDlqQueueUrl( deploymentDetails: DeploymentDetails, ): string { - return buildQueueUrl(deploymentDetails, "mock-client-dlq"); + return buildQueueUrl(deploymentDetails, "mock-target-1-dlq"); } export async function sendSqsEvent( diff --git a/tests/integration/jest.global-setup.ts b/tests/integration/jest.global-setup.ts index 3f4d58bd..19ef12b6 100644 --- a/tests/integration/jest.global-setup.ts +++ b/tests/integration/jest.global-setup.ts @@ -11,25 +11,25 @@ const mockClientSubscriptionBody = JSON.stringify({ clientId: "mock-client", subscriptions: [ { - subscriptionId: "mock-client-message", + subscriptionId: "mock-subscription-message-status", subscriptionType: "MessageStatus", - messageStatuses: ["DELIVERED"], - targetIds: ["445527ff-277b-43a4-a4b0-15eedbd71597"], + messageStatuses: ["DELIVERED", "FAILED"], + targetIds: ["mock-target-1"], }, { - subscriptionId: "mock-client-channel", + subscriptionId: "mock-subscription-channel-status", subscriptionType: "ChannelStatus", - channelStatuses: ["DELIVERED"], + channelStatuses: ["DELIVERED", "FAILED"], channelType: "NHSAPP", - supplierStatuses: ["delivered"], - targetIds: ["445527ff-277b-43a4-a4b0-15eedbd71597"], + supplierStatuses: ["delivered", "permanent_failure"], + targetIds: ["mock-target-1"], }, ], targets: [ { type: "API", - targetId: "445527ff-277b-43a4-a4b0-15eedbd71597", - invocationEndpoint: "https://some-mock-client.endpoint/webhook", + targetId: "mock-target-1", + invocationEndpoint: "https://placeholder.local/mock-webhook", invocationMethod: "POST", invocationRateLimit: 10, apiKey: { From bc7a267c106b923a19a4052187bac37ebdbe3484 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Mon, 23 Mar 2026 17:24:55 +0000 Subject: [PATCH 03/55] Fix webhook raw payload logging --- .../cloudwatch_event_rule_main.tf | 4 ++-- lambdas/mock-webhook-lambda/src/index.ts | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf b/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf index 2586116e..18d1eb03 100644 --- a/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf +++ b/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf @@ -28,10 +28,10 @@ resource "aws_cloudwatch_event_target" "per_subscription_target" { input_transformer { input_paths = { signature = "$.detail.signatures.${replace(each.value.target_id, "-", "_")}" - payload = "$.detail.payload" + data = "$.detail.payload.data" } - input_template = "" + input_template = "{\"data\": }" } http_target { diff --git a/lambdas/mock-webhook-lambda/src/index.ts b/lambdas/mock-webhook-lambda/src/index.ts index feddddb6..87ab3be7 100644 --- a/lambdas/mock-webhook-lambda/src/index.ts +++ b/lambdas/mock-webhook-lambda/src/index.ts @@ -36,9 +36,18 @@ function isClientCallbackPayload( async function buildResponse( event: APIGatewayProxyEvent, ): Promise { + const eventWithFunctionUrlFields = event as APIGatewayProxyEvent & { + rawPath?: string; + requestContext?: { http?: { method?: string } }; + }; + logger.info("Mock webhook invoked", { - path: event.path, - method: event.httpMethod, + path: event.path ?? eventWithFunctionUrlFields.rawPath, + method: + event.httpMethod ?? + eventWithFunctionUrlFields.requestContext?.http?.method, + hasBody: Boolean(event.body), + payload: event.body, }); const headers = Object.fromEntries( @@ -68,6 +77,8 @@ async function buildResponse( try { const parsed = JSON.parse(event.body) as unknown; + logger.info("Mock webhook parsed payload", { parsedPayload: parsed }); + if (!isClientCallbackPayload(parsed)) { logger.error("Invalid message structure - missing or invalid data array"); From b7c03b487d062ec71adfa63632d1e19cb11f2538 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Mon, 23 Mar 2026 17:41:42 +0000 Subject: [PATCH 04/55] Remove unused clients var --- .../terraform/components/callbacks/README.md | 1 - .../terraform/components/callbacks/variables.tf | 14 -------------- 2 files changed, 15 deletions(-) diff --git a/infrastructure/terraform/components/callbacks/README.md b/infrastructure/terraform/components/callbacks/README.md index f5056ab5..863bde34 100644 --- a/infrastructure/terraform/components/callbacks/README.md +++ b/infrastructure/terraform/components/callbacks/README.md @@ -15,7 +15,6 @@ |------|-------------|------|---------|:--------:| | [applications\_map\_parameter\_name](#input\_applications\_map\_parameter\_name) | SSM Parameter Store path for the clientId-to-applicationData map, where applicationData is currently only the applicationId | `string` | `null` | no | | [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes | -| [clients](#input\_clients) | n/a |
list(object({
connection_name = string
destination_name = string
invocation_endpoint = string
invocation_rate_limit_per_second = optional(number, 10)
http_method = optional(string, "POST")
header_name = optional(string, "x-api-key")
header_value = string
client_detail = list(string)
}))
| `[]` | no | | [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"callbacks"` | no | | [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | | [deploy\_mock\_webhook](#input\_deploy\_mock\_webhook) | Flag to deploy mock webhook lambda for integration testing (test/dev environments only) | `bool` | `false` | no | diff --git a/infrastructure/terraform/components/callbacks/variables.tf b/infrastructure/terraform/components/callbacks/variables.tf index b82546b0..6003d9e2 100644 --- a/infrastructure/terraform/components/callbacks/variables.tf +++ b/infrastructure/terraform/components/callbacks/variables.tf @@ -87,21 +87,7 @@ variable "pipe_event_patterns" { default = [] } -variable "clients" { - type = list(object({ - connection_name = string - destination_name = string - invocation_endpoint = string - invocation_rate_limit_per_second = optional(number, 10) - http_method = optional(string, "POST") - header_name = optional(string, "x-api-key") - header_value = string - client_detail = list(string) - })) - default = [] - -} variable "pipe_log_level" { type = string From 37b6a728ca1bc617c652a38b05cae6ec0153ef38 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Tue, 24 Mar 2026 09:49:04 +0000 Subject: [PATCH 05/55] Log headers in mock lambda --- lambdas/mock-webhook-lambda/jest.config.ts | 1 + lambdas/mock-webhook-lambda/src/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/lambdas/mock-webhook-lambda/jest.config.ts b/lambdas/mock-webhook-lambda/jest.config.ts index 571b3a87..3eb254b6 100644 --- a/lambdas/mock-webhook-lambda/jest.config.ts +++ b/lambdas/mock-webhook-lambda/jest.config.ts @@ -5,6 +5,7 @@ export default { coverageThreshold: { global: { ...nodeJestConfig.coverageThreshold?.global, + branches: 93, lines: 100, statements: 100, }, diff --git a/lambdas/mock-webhook-lambda/src/index.ts b/lambdas/mock-webhook-lambda/src/index.ts index 87ab3be7..fb46f13b 100644 --- a/lambdas/mock-webhook-lambda/src/index.ts +++ b/lambdas/mock-webhook-lambda/src/index.ts @@ -47,6 +47,7 @@ async function buildResponse( event.httpMethod ?? eventWithFunctionUrlFields.requestContext?.http?.method, hasBody: Boolean(event.body), + headers: event.headers, payload: event.body, }); From ceca90824d5e83bcb7308453cb79d7d0f8b3dcd2 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Tue, 24 Mar 2026 13:03:31 +0000 Subject: [PATCH 06/55] Create clients using terraform/tool --- .../terraform/components/callbacks/README.md | 94 ++++++++++--------- .../terraform/components/callbacks/pre.sh | 10 +- .../callbacks/sync-client-config.sh | 38 ++++++++ tests/integration/jest.global-setup.ts | 60 +----------- tests/integration/jest.global-teardown.ts | 25 +---- 5 files changed, 98 insertions(+), 129 deletions(-) create mode 100755 infrastructure/terraform/components/callbacks/sync-client-config.sh diff --git a/infrastructure/terraform/components/callbacks/README.md b/infrastructure/terraform/components/callbacks/README.md index 863bde34..98425660 100644 --- a/infrastructure/terraform/components/callbacks/README.md +++ b/infrastructure/terraform/components/callbacks/README.md @@ -4,57 +4,61 @@ ## Requirements -| Name | Version | -|------|---------| -| [terraform](#requirement\_terraform) | >= 1.10.1 | -| [aws](#requirement\_aws) | 6.13 | -| [random](#requirement\_random) | ~> 3.0 | +| Name | Version | +| ------------------------------------------------------------------------ | --------- | +| [terraform](#requirement_terraform) | >= 1.10.1 | +| [aws](#requirement_aws) | 6.13 | +| [random](#requirement_random) | ~> 3.0 | + ## Inputs -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [applications\_map\_parameter\_name](#input\_applications\_map\_parameter\_name) | SSM Parameter Store path for the clientId-to-applicationData map, where applicationData is currently only the applicationId | `string` | `null` | no | -| [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes | -| [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"callbacks"` | no | -| [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | -| [deploy\_mock\_webhook](#input\_deploy\_mock\_webhook) | Flag to deploy mock webhook lambda for integration testing (test/dev environments only) | `bool` | `false` | no | -| [enable\_event\_anomaly\_detection](#input\_enable\_event\_anomaly\_detection) | Enable CloudWatch anomaly detection alarm for inbound event queue message reception | `bool` | `true` | no | -| [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes | -| [event\_anomaly\_band\_width](#input\_event\_anomaly\_band\_width) | The width of the anomaly detection band. Higher values (e.g. 4-6) reduce sensitivity and noise, lower values (e.g. 2-3) increase sensitivity. Recommended: 2-4. | `number` | `3` | no | -| [event\_anomaly\_evaluation\_periods](#input\_event\_anomaly\_evaluation\_periods) | Number of evaluation periods for the anomaly alarm. Each period is defined by event\_anomaly\_period. | `number` | `2` | no | -| [event\_anomaly\_period](#input\_event\_anomaly\_period) | The period in seconds over which the specified statistic is applied for anomaly detection. Minimum 300 seconds (5 minutes). Recommended: 300-600. | `number` | `300` | no | -| [force\_lambda\_code\_deploy](#input\_force\_lambda\_code\_deploy) | If the lambda package in s3 has the same commit id tag as the terraform build branch, the lambda will not update automatically. Set to True if making changes to Lambda code from on the same commit for example during development | `bool` | `false` | no | -| [group](#input\_group) | The group variables are being inherited from (often synonmous with account short-name) | `string` | n/a | yes | -| [kms\_deletion\_window](#input\_kms\_deletion\_window) | When a kms key is deleted, how long should it wait in the pending deletion state? | `string` | `"30"` | no | -| [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no | -| [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no | -| [message\_root\_uri](#input\_message\_root\_uri) | The root URI used for constructing message links in callback payloads | `string` | n/a | yes | -| [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no | -| [pipe\_event\_patterns](#input\_pipe\_event\_patterns) | value | `list(string)` | `[]` | no | -| [pipe\_log\_level](#input\_pipe\_log\_level) | Log level for the EventBridge Pipe. | `string` | `"ERROR"` | no | -| [pipe\_sqs\_input\_batch\_size](#input\_pipe\_sqs\_input\_batch\_size) | n/a | `number` | `1` | no | -| [pipe\_sqs\_max\_batch\_window](#input\_pipe\_sqs\_max\_batch\_window) | n/a | `number` | `2` | no | -| [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | -| [region](#input\_region) | The AWS Region | `string` | n/a | yes | -| [sqs\_inbound\_event\_max\_receive\_count](#input\_sqs\_inbound\_event\_max\_receive\_count) | n/a | `number` | `3` | no | -| [sqs\_inbound\_event\_visibility\_timeout\_seconds](#input\_sqs\_inbound\_event\_visibility\_timeout\_seconds) | n/a | `number` | `60` | no | +| Name | Description | Type | Default | Required | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ------------- | :------: | +| [applications_map_parameter_name](#input_applications_map_parameter_name) | SSM Parameter Store path for the clientId-to-applicationData map, where applicationData is currently only the applicationId | `string` | `null` | no | +| [aws_account_id](#input_aws_account_id) | The AWS Account ID (numeric) | `string` | n/a | yes | +| [component](#input_component) | The variable encapsulating the name of this component | `string` | `"callbacks"` | no | +| [default_tags](#input_default_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | +| [deploy_mock_webhook](#input_deploy_mock_webhook) | Flag to deploy mock webhook lambda for integration testing (test/dev environments only) | `bool` | `false` | no | +| [enable_event_anomaly_detection](#input_enable_event_anomaly_detection) | Enable CloudWatch anomaly detection alarm for inbound event queue message reception | `bool` | `true` | no | +| [environment](#input_environment) | The name of the tfscaffold environment | `string` | n/a | yes | +| [event_anomaly_band_width](#input_event_anomaly_band_width) | The width of the anomaly detection band. Higher values (e.g. 4-6) reduce sensitivity and noise, lower values (e.g. 2-3) increase sensitivity. Recommended: 2-4. | `number` | `3` | no | +| [event_anomaly_evaluation_periods](#input_event_anomaly_evaluation_periods) | Number of evaluation periods for the anomaly alarm. Each period is defined by event_anomaly_period. | `number` | `2` | no | +| [event_anomaly_period](#input_event_anomaly_period) | The period in seconds over which the specified statistic is applied for anomaly detection. Minimum 300 seconds (5 minutes). Recommended: 300-600. | `number` | `300` | no | +| [force_lambda_code_deploy](#input_force_lambda_code_deploy) | If the lambda package in s3 has the same commit id tag as the terraform build branch, the lambda will not update automatically. Set to True if making changes to Lambda code from on the same commit for example during development | `bool` | `false` | no | +| [group](#input_group) | The group variables are being inherited from (often synonmous with account short-name) | `string` | n/a | yes | +| [kms_deletion_window](#input_kms_deletion_window) | When a kms key is deleted, how long should it wait in the pending deletion state? | `string` | `"30"` | no | +| [log_level](#input_log_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no | +| [log_retention_in_days](#input_log_retention_in_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no | +| [message_root_uri](#input_message_root_uri) | The root URI used for constructing message links in callback payloads | `string` | n/a | yes | +| [parent_acct_environment](#input_parent_acct_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no | +| [pipe_event_patterns](#input_pipe_event_patterns) | value | `list(string)` | `[]` | no | +| [pipe_log_level](#input_pipe_log_level) | Log level for the EventBridge Pipe. | `string` | `"ERROR"` | no | +| [pipe_sqs_input_batch_size](#input_pipe_sqs_input_batch_size) | n/a | `number` | `1` | no | +| [pipe_sqs_max_batch_window](#input_pipe_sqs_max_batch_window) | n/a | `number` | `2` | no | +| [project](#input_project) | The name of the tfscaffold project | `string` | n/a | yes | +| [region](#input_region) | The AWS Region | `string` | n/a | yes | +| [sqs_inbound_event_max_receive_count](#input_sqs_inbound_event_max_receive_count) | n/a | `number` | `3` | no | +| [sqs_inbound_event_visibility_timeout_seconds](#input_sqs_inbound_event_visibility_timeout_seconds) | n/a | `number` | `60` | no | + ## Modules -| Name | Source | Version | -|------|--------|---------| -| [client\_config\_bucket](#module\_client\_config\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | -| [client\_destination](#module\_client\_destination) | ../../modules/client-destination | n/a | -| [client\_transform\_filter\_lambda](#module\_client\_transform\_filter\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | -| [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-kms.zip | n/a | -| [mock\_webhook\_lambda](#module\_mock\_webhook\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | -| [sqs\_inbound\_event](#module\_sqs\_inbound\_event) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a | +| Name | Source | Version | +| ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------- | +| [client_config_bucket](#module_client_config_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | +| [client_destination](#module_client_destination) | ../../modules/client-destination | n/a | +| [client_transform_filter_lambda](#module_client_transform_filter_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | +| [kms](#module_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-kms.zip | n/a | +| [mock_webhook_lambda](#module_mock_webhook_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | +| [sqs_inbound_event](#module_sqs_inbound_event) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a | + ## Outputs -| Name | Description | -|------|-------------| -| [deployment](#output\_deployment) | Deployment details used for post-deployment scripts | -| [mock\_webhook\_lambda\_log\_group\_name](#output\_mock\_webhook\_lambda\_log\_group\_name) | CloudWatch log group name for mock webhook lambda (for integration test queries) | -| [mock\_webhook\_url](#output\_mock\_webhook\_url) | URL endpoint for mock webhook (for TEST\_WEBHOOK\_URL environment variable) | +| Name | Description | +| ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| [deployment](#output_deployment) | Deployment details used for post-deployment scripts | +| [mock_webhook_lambda_log_group_name](#output_mock_webhook_lambda_log_group_name) | CloudWatch log group name for mock webhook lambda (for integration test queries) | +| [mock_webhook_url](#output_mock_webhook_url) | URL endpoint for mock webhook (for TEST_WEBHOOK_URL environment variable) | + diff --git a/infrastructure/terraform/components/callbacks/pre.sh b/infrastructure/terraform/components/callbacks/pre.sh index 6f3957ec..a5427e71 100755 --- a/infrastructure/terraform/components/callbacks/pre.sh +++ b/infrastructure/terraform/components/callbacks/pre.sh @@ -1,9 +1,13 @@ -# # This script is run before the Terraform apply command. -# # It ensures all Node.js dependencies are installed, generates any required dependencies, -# # and builds all Lambda functions in the workspace before Terraform provisions infrastructure. +# This script is run before the Terraform apply command. +# It ensures dependencies are installed, generates local client config files +# for terraform from S3-held subscriptions, and builds lambda workspaces. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" npm ci npm run generate-dependencies --workspaces --if-present +"${script_dir}/sync-client-config.sh" + npm run lambda-build --workspaces --if-present diff --git a/infrastructure/terraform/components/callbacks/sync-client-config.sh b/infrastructure/terraform/components/callbacks/sync-client-config.sh new file mode 100755 index 00000000..112fade4 --- /dev/null +++ b/infrastructure/terraform/components/callbacks/sync-client-config.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../../../.." && pwd)" +clients_dir="${repo_root}/infrastructure/terraform/modules/clients" + +: "${ENVIRONMENT:?ENVIRONMENT must be set}" +: "${AWS_REGION:?AWS_REGION must be set}" + +cd "${repo_root}" + +rm -f "${clients_dir}"/*.json + +account_id="${AWS_ACCOUNT_ID:-}" +if [[ -z "${account_id}" ]]; then + account_id="$(aws sts get-caller-identity --query Account --output text --region "${AWS_REGION}")" +fi + +bucket_name="nhs-${account_id}-${AWS_REGION}-${ENVIRONMENT}-callbacks-subscription-config" + +s3_prefix="client_subscriptions/" + +echo "Seeding client configs from s3://${bucket_name}/${s3_prefix} for ${ENVIRONMENT}/${AWS_REGION}" + +aws s3 sync "s3://${bucket_name}/${s3_prefix}" "${clients_dir}/" \ + --region "${AWS_REGION}" \ + --exclude "*" \ + --include "*.json" \ + --only-show-errors + +shopt -s nullglob +seeded_files=("${clients_dir}"/*.json) +seeded_count="${#seeded_files[@]}" +shopt -u nullglob + +echo "Seeded ${seeded_count} client config file(s)" diff --git a/tests/integration/jest.global-setup.ts b/tests/integration/jest.global-setup.ts index 19ef12b6..b083e798 100644 --- a/tests/integration/jest.global-setup.ts +++ b/tests/integration/jest.global-setup.ts @@ -1,60 +1,4 @@ -import { PutObjectCommand } from "@aws-sdk/client-s3"; -import { - buildSubscriptionConfigBucketName, - createS3Client, - getDeploymentDetails, -} from "./helpers"; - -const mockClientSubscriptionKey = "client_subscriptions/mock-client.json"; - -const mockClientSubscriptionBody = JSON.stringify({ - clientId: "mock-client", - subscriptions: [ - { - subscriptionId: "mock-subscription-message-status", - subscriptionType: "MessageStatus", - messageStatuses: ["DELIVERED", "FAILED"], - targetIds: ["mock-target-1"], - }, - { - subscriptionId: "mock-subscription-channel-status", - subscriptionType: "ChannelStatus", - channelStatuses: ["DELIVERED", "FAILED"], - channelType: "NHSAPP", - supplierStatuses: ["delivered", "permanent_failure"], - targetIds: ["mock-target-1"], - }, - ], - targets: [ - { - type: "API", - targetId: "mock-target-1", - invocationEndpoint: "https://placeholder.local/mock-webhook", - invocationMethod: "POST", - invocationRateLimit: 10, - apiKey: { - headerName: "x-api-key", - headerValue: "some-api-key", - }, - }, - ], -}); - export default async function globalSetup() { - const deploymentDetails = getDeploymentDetails(); - const bucketName = buildSubscriptionConfigBucketName(deploymentDetails); - const client = createS3Client(deploymentDetails); - - try { - await client.send( - new PutObjectCommand({ - Bucket: bucketName, - Key: mockClientSubscriptionKey, - ContentType: "application/json", - Body: mockClientSubscriptionBody, - }), - ); - } finally { - client.destroy(); - } + // No setup actions are required for client subscription config. + // Config is now loaded via tooling before terraform apply. } diff --git a/tests/integration/jest.global-teardown.ts b/tests/integration/jest.global-teardown.ts index 192c1892..fb78cebe 100644 --- a/tests/integration/jest.global-teardown.ts +++ b/tests/integration/jest.global-teardown.ts @@ -1,25 +1,4 @@ -import { DeleteObjectCommand } from "@aws-sdk/client-s3"; -import { - buildSubscriptionConfigBucketName, - createS3Client, - getDeploymentDetails, -} from "./helpers"; - -const mockClientSubscriptionKey = "client_subscriptions/mock-client.json"; - export default async function globalTeardown() { - const deploymentDetails = getDeploymentDetails(); - const bucketName = buildSubscriptionConfigBucketName(deploymentDetails); - const client = createS3Client(deploymentDetails); - - try { - await client.send( - new DeleteObjectCommand({ - Bucket: bucketName, - Key: mockClientSubscriptionKey, - }), - ); - } finally { - client.destroy(); - } + // No teardown actions are required for client subscription config. + // Config is now loaded via tooling before terraform apply. } From f120379f954e40f1f9100814687a115f25f05b6f Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Tue, 24 Mar 2026 13:54:44 +0000 Subject: [PATCH 07/55] Config driven IT subscriptions --- package.json | 1 + scripts/tests/integration.sh | 24 ++++++++++ tests/integration/helpers/event-factories.ts | 6 ++- tests/integration/helpers/index.ts | 1 + .../helpers/mock-client-subscription.json | 45 +++++++++++++++++++ tests/integration/helpers/sqs.ts | 4 +- tests/integration/tsconfig.json | 3 +- 7 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 tests/integration/helpers/mock-client-subscription.json diff --git a/package.json b/package.json index 68238b47..2954ba4f 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "test:unit:silent": "LOG_LEVEL=silent npm run test:unit --workspaces", "typecheck": "npm run typecheck --workspaces", "verify": "npm run lint && npm run typecheck && npm run test:unit", + "applications-map:add": "npm run --silent applications-map-add --workspace tools/client-subscriptions-management --", "clients:list": "npm run --silent clients-list --workspace tools/client-subscriptions-management --", "clients:get": "npm run --silent clients-get --workspace tools/client-subscriptions-management --", "clients:put": "npm run --silent clients-put --workspace tools/client-subscriptions-management --", diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index 8460d02b..43b1da49 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -5,4 +5,28 @@ set -euo pipefail cd "$(git rev-parse --show-toplevel)" npm ci + +SEED_CONFIG_FILE="$(pwd)/tests/integration/helpers/mock-client-subscription.json" +CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") +FUNCTION_NAME="nhs-${ENVIRONMENT}-callbacks-mock-webhook" +MOCK_WEBHOOK_URL=$(aws lambda get-function-url-config \ + --function-name "${FUNCTION_NAME}" \ + --region eu-west-2 \ + --query 'FunctionUrl' --output text) +MOCK_WEBHOOK_API_KEY=$(aws lambda get-function-configuration \ + --function-name "${FUNCTION_NAME}" \ + --region eu-west-2 \ + --query 'Environment.Variables.API_KEY' --output text) +SEED_CONFIG_JSON=$(jq \ + --arg url "${MOCK_WEBHOOK_URL}" \ + --arg key "${MOCK_WEBHOOK_API_KEY}" \ + '.targets[0].invocationEndpoint = $url | .targets[0].apiKey.headerValue = $key' \ + "${SEED_CONFIG_FILE}") + +npm run clients:put -- \ + --client-id "${CLIENT_ID}" \ + --environment "${ENVIRONMENT}" \ + --region eu-west-2 \ + --json "${SEED_CONFIG_JSON}" + npm run test:integration diff --git a/tests/integration/helpers/event-factories.ts b/tests/integration/helpers/event-factories.ts index 3e25ad3f..bded4759 100644 --- a/tests/integration/helpers/event-factories.ts +++ b/tests/integration/helpers/event-factories.ts @@ -5,6 +5,8 @@ import type { } from "@nhs-notify-client-callbacks/models"; import { EventTypes } from "@nhs-notify-client-callbacks/models"; +import { getSeedConfig } from "./seed-config"; + type MessageEventOverrides = { event?: Partial>; data?: Partial; @@ -23,7 +25,7 @@ export function createMessageStatusPublishEvent( overrides?.data?.messageReference ?? `ref-${crypto.randomUUID()}`; const baseData: MessageStatusData = { - clientId: "mock-client", + clientId: getSeedConfig().clientId, messageId, messageReference, messageStatus: "DELIVERED", @@ -78,7 +80,7 @@ export function createChannelStatusPublishEvent( overrides?.data?.messageReference ?? `ref-${crypto.randomUUID()}`; const baseData: ChannelStatusData = { - clientId: "mock-client", + clientId: getSeedConfig().clientId, messageId, messageReference, channel: "NHSAPP", diff --git a/tests/integration/helpers/index.ts b/tests/integration/helpers/index.ts index 7f021566..badf046e 100644 --- a/tests/integration/helpers/index.ts +++ b/tests/integration/helpers/index.ts @@ -3,6 +3,7 @@ export * from "./clients"; export * from "./sqs"; export * from "./cloudwatch"; export { default as sendEventToDlqAndRedrive } from "./redrive"; +export * from "./seed-config"; export * from "./status-events"; export * from "./event-factories"; export * from "./signature"; diff --git a/tests/integration/helpers/mock-client-subscription.json b/tests/integration/helpers/mock-client-subscription.json new file mode 100644 index 00000000..23a9c035 --- /dev/null +++ b/tests/integration/helpers/mock-client-subscription.json @@ -0,0 +1,45 @@ +{ + "clientId": "mock-seed-client", + "subscriptions": [ + { + "messageStatuses": [ + "DELIVERED", + "FAILED" + ], + "subscriptionId": "sub-28fc741d-de6c-41a9-8fb0-89c4115c7dcf", + "subscriptionType": "MessageStatus", + "targetIds": [ + "target-23b2ee2f-8e81-43cd-9bb8-5ea30a09f779" + ] + }, + { + "channelStatuses": [ + "DELIVERED", + "FAILED" + ], + "channelType": "NHSAPP", + "subscriptionId": "sub-0fa7076d-5640-47ea-9f8f-70ff20778571", + "subscriptionType": "ChannelStatus", + "supplierStatuses": [ + "delivered", + "permanent_failure" + ], + "targetIds": [ + "target-23b2ee2f-8e81-43cd-9bb8-5ea30a09f779" + ] + } + ], + "targets": [ + { + "apiKey": { + "headerName": "x-api-key", + "headerValue": "REPLACED_BY_SCRIPT" + }, + "invocationEndpoint": "https://REPLACED_BY_SCRIPT", + "invocationMethod": "POST", + "invocationRateLimit": 10, + "targetId": "target-23b2ee2f-8e81-43cd-9bb8-5ea30a09f779", + "type": "API" + } + ] +} diff --git a/tests/integration/helpers/sqs.ts b/tests/integration/helpers/sqs.ts index 858923e4..d0970aa1 100644 --- a/tests/integration/helpers/sqs.ts +++ b/tests/integration/helpers/sqs.ts @@ -12,6 +12,7 @@ import { logger } from "@nhs-notify-client-callbacks/logger"; import { waitUntil } from "async-wait-until"; import type { DeploymentDetails } from "./deployment"; +import { getSeedConfig } from "./seed-config"; const QUEUE_WAIT_TIMEOUT_MS = 60_000; const POLL_INTERVAL_MS = 500; @@ -62,7 +63,8 @@ export function buildInboundEventDlqQueueUrl( export function buildMockClientDlqQueueUrl( deploymentDetails: DeploymentDetails, ): string { - return buildQueueUrl(deploymentDetails, "mock-target-1-dlq"); + const { targets } = getSeedConfig(); + return buildQueueUrl(deploymentDetails, `${targets[0].targetId}-dlq`); } export async function sendSqsEvent( diff --git a/tests/integration/tsconfig.json b/tests/integration/tsconfig.json index a5cc2b81..01538bd3 100644 --- a/tests/integration/tsconfig.json +++ b/tests/integration/tsconfig.json @@ -6,7 +6,8 @@ "helpers": [ "./helpers/index" ] - } + }, + "resolveJsonModule": true }, "extends": "../../tsconfig.base.json", "include": [ From b9643b3cf98b5dccf52b0742a1a2db1aeb3f82ef Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Tue, 24 Mar 2026 14:37:36 +0000 Subject: [PATCH 08/55] Tool to add application ids --- scripts/tests/integration.sh | 2 + .../package.json | 2 + .../src/__tests__/aws.test.ts | 36 ++++++ .../cli/applications-map-add.test.ts | 109 ++++++++++++++++++ .../src/__tests__/format.test.ts | 20 ++++ .../repository/ssm-applications-map.test.ts | 98 ++++++++++++++++ .../src/aws.ts | 48 ++++++++ .../entrypoint/cli/applications-map-add.ts | 60 ++++++++++ .../src/entrypoint/cli/helper.ts | 29 +++++ .../src/entrypoint/cli/index.ts | 2 + .../src/format.ts | 13 +++ .../src/repository/ssm-applications-map.ts | 54 +++++++++ 12 files changed, 473 insertions(+) create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-add.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/repository/ssm-applications-map.test.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/applications-map-add.ts create mode 100644 tools/client-subscriptions-management/src/repository/ssm-applications-map.ts diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index 43b1da49..ffa0edcd 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -29,4 +29,6 @@ npm run clients:put -- \ --region eu-west-2 \ --json "${SEED_CONFIG_JSON}" +npm run applications-map:add -- --client-id "${CLIENT_ID}" --application-id some-application-id + npm run test:integration diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json index b675916e..55782eff 100644 --- a/tools/client-subscriptions-management/package.json +++ b/tools/client-subscriptions-management/package.json @@ -13,6 +13,7 @@ "targets-list": "tsx ./src/entrypoint/cli/index.ts targets-list", "targets-add": "tsx ./src/entrypoint/cli/index.ts targets-add", "targets-del": "tsx ./src/entrypoint/cli/index.ts targets-del", + "applications-map-add": "tsx ./src/entrypoint/cli/index.ts applications-map-add", "lint": "eslint .", "lint:fix": "eslint . --fix", "test:unit": "jest", @@ -20,6 +21,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.821.0", + "@aws-sdk/client-ssm": "^3.1011.0", "@aws-sdk/client-sts": "^3.1004.0", "@aws-sdk/credential-providers": "^3.1004.0", "@nhs-notify-client-callbacks/models": "*", diff --git a/tools/client-subscriptions-management/src/__tests__/aws.test.ts b/tools/client-subscriptions-management/src/__tests__/aws.test.ts index f2c3f4e1..f08d0bda 100644 --- a/tools/client-subscriptions-management/src/__tests__/aws.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/aws.test.ts @@ -1,6 +1,8 @@ import { deriveBucketName, + deriveParameterName, resolveBucketName, + resolveParameterName, resolveProfile, resolveRegion, } from "src/aws"; @@ -78,4 +80,38 @@ describe("aws", () => { it("returns undefined when region is not set", () => { expect(resolveRegion(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined(); }); + + it("derives parameter name from environment", () => { + expect(deriveParameterName("dev")).toBe( + "/nhs/dev/callbacks/applications-map", + ); + }); + + it("resolves parameter name from explicit argument", () => { + expect(resolveParameterName({ parameterName: "/custom/path" })).toBe( + "/custom/path", + ); + }); + + it("derives parameter name from environment argument", () => { + expect(resolveParameterName({ environment: "dev" })).toBe( + "/nhs/dev/callbacks/applications-map", + ); + }); + + it("derives parameter name from ENVIRONMENT env var", () => { + expect( + resolveParameterName({ + env: { ENVIRONMENT: "staging" } as NodeJS.ProcessEnv, + }), + ).toBe("/nhs/staging/callbacks/applications-map"); + }); + + it("throws when no parameter name can be resolved", () => { + expect(() => + resolveParameterName({ env: {} as NodeJS.ProcessEnv }), + ).toThrow( + "Environment is required to derive parameter name. Please provide via --environment or ENVIRONMENT env var.", + ); + }); }); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-add.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-add.test.ts new file mode 100644 index 00000000..99b08ca9 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-add.test.ts @@ -0,0 +1,109 @@ +import * as cli from "src/entrypoint/cli/applications-map-add"; +import * as helper from "src/entrypoint/cli/helper"; +import { + captureCliConsoleState, + expectWrappedCliError, + resetCliConsoleState, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; + +const mockAddApplication = jest.fn(); +const mockFormatApplicationsMap = jest.fn(); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createSsmApplicationsMapRepository: jest.fn(), +})); + +jest.mock("src/format", () => ({ + ...jest.requireActual("src/format"), + formatApplicationsMap: (...args: unknown[]) => + mockFormatApplicationsMap(...args), +})); + +const mockCreateSsmApplicationsMapRepository = + helper.createSsmApplicationsMapRepository as jest.Mock; + +describe("applications-map-add CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + const baseArgs = [ + "node", + "script", + "--client-id", + "client-1", + "--application-id", + "app-1", + "--parameter-name", + "/nhs/dev/callbacks/applications-map", + ]; + + const resultMap = new Map([["client-1", "app-1"]]); + + beforeEach(() => { + mockAddApplication.mockReset(); + mockAddApplication.mockResolvedValue(resultMap); + mockFormatApplicationsMap.mockReset(); + mockFormatApplicationsMap.mockReturnValue("masked-map-output"); + mockCreateSsmApplicationsMapRepository.mockReset(); + mockCreateSsmApplicationsMapRepository.mockReturnValue({ + addApplication: mockAddApplication, + }); + resetCliConsoleState(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("adds application and logs output", async () => { + await cli.main(baseArgs); + + expect(mockCreateSsmApplicationsMapRepository).toHaveBeenCalledWith( + expect.objectContaining({ + "client-id": "client-1", + "application-id": "app-1", + "parameter-name": "/nhs/dev/callbacks/applications-map", + }), + ); + expect(mockAddApplication).toHaveBeenCalledWith("client-1", "app-1", false); + expect(console.log).toHaveBeenCalledWith( + "Applications map updated for client 'client-1'.", + ); + expect(mockFormatApplicationsMap).toHaveBeenCalledWith(resultMap); + expect(console.log).toHaveBeenCalledWith("masked-map-output"); + }); + + it("does not log application-id", async () => { + await cli.main(baseArgs); + + const logMessages = (console.log as jest.Mock).mock.calls.flat(); + expect(logMessages).not.toContain("app-1"); + }); + + it("does not log dry-run message when dry-run is false", async () => { + await cli.main(baseArgs); + + expect(console.log).not.toHaveBeenCalledWith( + "Dry run — no changes written to SSM.", + ); + }); + + it("passes dry-run flag to repository and logs dry-run message", async () => { + await cli.main([...baseArgs, "--dry-run"]); + + expect(mockAddApplication).toHaveBeenCalledWith("client-1", "app-1", true); + expect(console.log).toHaveBeenCalledWith( + "Dry run — no changes written to SSM.", + ); + }); + + it("handles errors in wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateSsmApplicationsMapRepository.mockReturnValue({ + addApplication: jest.fn().mockRejectedValue(new Error("Boom")), + }); + + await expectWrappedCliError(cli.main, baseArgs); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/format.test.ts b/tools/client-subscriptions-management/src/__tests__/format.test.ts index ef0b80cf..a8c83570 100644 --- a/tools/client-subscriptions-management/src/__tests__/format.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/format.test.ts @@ -1,4 +1,5 @@ import { + formatApplicationsMap, formatClientConfig, formatSubscriptionsTable, formatTargetsTable, @@ -73,4 +74,23 @@ describe("format", () => { it("normalizes client name", () => { expect(normalizeClientName("My Client Name")).toBe("my-client-name"); }); + + it("formats empty applications map", () => { + expect(formatApplicationsMap(new Map())).toBe("Applications map: (empty)"); + }); + + it("masks application IDs in applications map output", () => { + const result = formatApplicationsMap( + new Map([ + ["client-a", "app-12345"], + ["client-b", "a"], + ]), + ); + + expect(result).toContain("client-a"); + expect(result).toContain("client-b"); + expect(result).toContain("*********"); + expect(result).toContain("*"); + expect(result).not.toContain("app-12345"); + }); }); diff --git a/tools/client-subscriptions-management/src/__tests__/repository/ssm-applications-map.test.ts b/tools/client-subscriptions-management/src/__tests__/repository/ssm-applications-map.test.ts new file mode 100644 index 00000000..b169c5d5 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/repository/ssm-applications-map.test.ts @@ -0,0 +1,98 @@ +import { + GetParameterCommand, + PutParameterCommand, + type SSMClient, +} from "@aws-sdk/client-ssm"; +import SsmApplicationsMapRepository from "src/repository/ssm-applications-map"; + +const createRepository = (send: jest.Mock = jest.fn()) => { + const client = { send } as unknown as SSMClient; + return { + repository: new SsmApplicationsMapRepository(client, "/test/param"), + send, + }; +}; + +describe("SsmApplicationsMapRepository", () => { + describe("addApplication", () => { + it("reads existing map, merges new entry, and writes back", async () => { + const { repository, send } = createRepository(); + send + .mockResolvedValueOnce({ + Parameter: { + Value: JSON.stringify({ "existing-client": "existing-app" }), + }, + }) + .mockResolvedValueOnce({}); + + const result = await repository.addApplication("client-1", "app-1"); + + expect(send).toHaveBeenNthCalledWith(1, expect.any(GetParameterCommand)); + expect(send).toHaveBeenNthCalledWith(2, expect.any(PutParameterCommand)); + expect(result).toEqual( + new Map([ + ["existing-client", "existing-app"], + ["client-1", "app-1"], + ]), + ); + }); + + it("starts from empty map when parameter does not exist", async () => { + const { repository, send } = createRepository(); + const error = Object.assign(new Error("not found"), { + name: "ParameterNotFound", + }); + send.mockRejectedValueOnce(error).mockResolvedValueOnce({}); + + const result = await repository.addApplication("client-1", "app-1"); + + expect(result).toEqual(new Map([["client-1", "app-1"]])); + expect(send).toHaveBeenCalledTimes(2); + }); + + it("starts from empty map when parameter has no value", async () => { + const { repository, send } = createRepository(); + send.mockResolvedValueOnce({ Parameter: {} }).mockResolvedValueOnce({}); + + const result = await repository.addApplication("client-1", "app-1"); + + expect(result).toEqual(new Map([["client-1", "app-1"]])); + }); + + it("overwrites an existing client entry", async () => { + const { repository, send } = createRepository(); + send + .mockResolvedValueOnce({ + Parameter: { Value: JSON.stringify({ "client-1": "old-app" }) }, + }) + .mockResolvedValueOnce({}); + + const result = await repository.addApplication("client-1", "new-app"); + + expect(result).toEqual(new Map([["client-1", "new-app"]])); + }); + + it("skips the put when dry-run is true", async () => { + const { repository, send } = createRepository(); + send.mockResolvedValueOnce({ + Parameter: { Value: JSON.stringify({}) }, + }); + + const result = await repository.addApplication("client-1", "app-1", true); + + expect(send).toHaveBeenCalledTimes(1); + expect(result).toEqual(new Map([["client-1", "app-1"]])); + }); + + it("rethrows unexpected SSM errors", async () => { + const { repository, send } = createRepository(); + send.mockRejectedValueOnce( + Object.assign(new Error("Network failure"), { name: "NetworkError" }), + ); + + await expect( + repository.addApplication("client-1", "app-1"), + ).rejects.toThrow("Network failure"); + }); + }); +}); diff --git a/tools/client-subscriptions-management/src/aws.ts b/tools/client-subscriptions-management/src/aws.ts index e2272f35..5599b50b 100644 --- a/tools/client-subscriptions-management/src/aws.ts +++ b/tools/client-subscriptions-management/src/aws.ts @@ -1,7 +1,9 @@ import { S3Client } from "@aws-sdk/client-s3"; +import { SSMClient } from "@aws-sdk/client-ssm"; import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; import { fromIni } from "@aws-sdk/credential-providers"; import { ClientSubscriptionRepository } from "src/repository/client-subscriptions"; +import SsmApplicationsMapRepository from "src/repository/ssm-applications-map"; import { S3Repository } from "src/repository/s3"; export const resolveProfile = ( @@ -87,3 +89,49 @@ export const createRepository = (options: { ); return new ClientSubscriptionRepository(s3Repository); }; + +export const createSsmClient = ( + region?: string, + profile?: string, + env: NodeJS.ProcessEnv = process.env, +): SSMClient => { + const endpoint = env.AWS_ENDPOINT_URL; + const credentials = profile ? fromIni({ profile }) : undefined; + return new SSMClient({ region, endpoint, credentials }); +}; + +export const deriveParameterName = (environment: string): string => + `/nhs/${environment}/callbacks/applications-map`; + +export const resolveParameterName = (args: { + parameterName?: string; + environment?: string; + env?: NodeJS.ProcessEnv; +}): string => { + const { env = process.env, environment, parameterName } = args; + + if (parameterName) { + return parameterName; + } + + const resolvedEnvironment = environment ?? env.ENVIRONMENT; + if (!resolvedEnvironment) { + throw new Error( + "Environment is required to derive parameter name. Please provide via --environment or ENVIRONMENT env var.", + ); + } + + return deriveParameterName(resolvedEnvironment); +}; + +export const createSsmApplicationsMapRepository = (options: { + parameterName: string; + region?: string; + profile?: string; +}): SsmApplicationsMapRepository => + new SsmApplicationsMapRepository( + createSsmClient(options.region, options.profile), + options.parameterName, + ); + +export { default as SsmApplicationsMapRepository } from "src/repository/ssm-applications-map"; diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-add.ts b/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-add.ts new file mode 100644 index 00000000..a98e574f --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-add.ts @@ -0,0 +1,60 @@ +import type { Argv } from "yargs"; +import { + type CliCommand, + type ClientCliArgs, + type SsmCliArgs, + type WriteCliArgs, + clientIdOption, + commonOptions, + createSsmApplicationsMapRepository, + parameterNameOption, + runCommand, + writeOptions, +} from "src/entrypoint/cli/helper"; +import { formatApplicationsMap } from "src/format"; + +type ApplicationsMapAddArgs = ClientCliArgs & + SsmCliArgs & + WriteCliArgs & { + "application-id": string; + }; + +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + ...parameterNameOption, + ...writeOptions, + "application-id": { + type: "string", + demandOption: true, + description: "Application ID to associate with the client", + }, + }); + +export const handler: CliCommand["handler"] = async ( + argv, +) => { + const repository = createSsmApplicationsMapRepository(argv); + const result = await repository.addApplication( + argv["client-id"], + argv["application-id"], + argv["dry-run"], + ); + console.log(`Applications map updated for client '${argv["client-id"]}'.`); + if (argv["dry-run"]) { + console.log("Dry run — no changes written to SSM."); + } + console.log(formatApplicationsMap(result)); +}; + +export const command: CliCommand = { + command: "applications-map-add", + describe: "Add or update a client-to-application-ID mapping in SSM", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts index e9a94487..14e998dd 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/helper.ts @@ -1,6 +1,8 @@ import { createRepository as createRepositoryFromOptions, + createSsmApplicationsMapRepository as createSsmApplicationsMapRepositoryFromOptions, resolveBucketName, + resolveParameterName as resolveParameterNameFromAws, resolveProfile, resolveRegion, } from "src/aws"; @@ -130,3 +132,30 @@ export const writeOptions = { description: "Validate config without writing to S3", }, }; + +export type SsmCliArgs = CommonCliArgs & { + "parameter-name"?: string; +}; + +export const parameterNameOption = { + "parameter-name": { + type: "string" as const, + demandOption: false as const, + description: + "Explicit SSM parameter name for the applications map (overrides derived name)", + }, +}; + +export const createSsmApplicationsMapRepository = (argv: SsmCliArgs) => { + const region = resolveRegion(argv.region); + const profile = resolveProfile(argv.profile); + const parameterName = resolveParameterNameFromAws({ + parameterName: argv["parameter-name"], + environment: argv.environment, + }); + return createSsmApplicationsMapRepositoryFromOptions({ + parameterName, + region, + profile, + }); +}; diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/index.ts b/tools/client-subscriptions-management/src/entrypoint/cli/index.ts index 88d1a733..450e94d1 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/index.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/index.ts @@ -1,3 +1,4 @@ +import { command as applicationsMapAddCommand } from "src/entrypoint/cli/applications-map-add"; import { command as clientsGetCommand } from "src/entrypoint/cli/clients-get"; import { command as clientsListCommand } from "src/entrypoint/cli/clients-list"; import { command as clientsPutCommand } from "src/entrypoint/cli/clients-put"; @@ -12,6 +13,7 @@ import { command as targetsDelCommand } from "src/entrypoint/cli/targets-del"; import { command as targetsListCommand } from "src/entrypoint/cli/targets-list"; export const commands: AnyCliCommand[] = [ + applicationsMapAddCommand, clientsListCommand, clientsGetCommand, clientsPutCommand, diff --git a/tools/client-subscriptions-management/src/format.ts b/tools/client-subscriptions-management/src/format.ts index 1c944c06..0944fd16 100644 --- a/tools/client-subscriptions-management/src/format.ts +++ b/tools/client-subscriptions-management/src/format.ts @@ -74,3 +74,16 @@ export const formatClientConfig = ( export const normalizeClientName = (name: string): string => name.replaceAll(/\s+/g, "-").toLowerCase(); + +const maskValue = (value: string): string => "*".repeat(value.length || 8); + +export const formatApplicationsMap = (map: Map): string => + map.size === 0 + ? "Applications map: (empty)" + : table([ + ["Client ID", "Application ID"], + ...[...map.entries()].map(([clientId, applicationId]) => [ + clientId, + maskValue(applicationId), + ]), + ]); diff --git a/tools/client-subscriptions-management/src/repository/ssm-applications-map.ts b/tools/client-subscriptions-management/src/repository/ssm-applications-map.ts new file mode 100644 index 00000000..04b41b9f --- /dev/null +++ b/tools/client-subscriptions-management/src/repository/ssm-applications-map.ts @@ -0,0 +1,54 @@ +import { + GetParameterCommand, + PutParameterCommand, + type SSMClient, +} from "@aws-sdk/client-ssm"; + +export default class SsmApplicationsMapRepository { + constructor( + private readonly client: SSMClient, + private readonly parameterName: string, + ) {} + + async addApplication( + clientId: string, + applicationId: string, + dryRun = false, + ): Promise> { + let current: Record = {}; + + try { + const response = await this.client.send( + new GetParameterCommand({ + Name: this.parameterName, + WithDecryption: true, + }), + ); + if (response.Parameter?.Value) { + current = JSON.parse(response.Parameter.Value) as Record< + string, + string + >; + } + } catch (error) { + if (error instanceof Error && error.name !== "ParameterNotFound") { + throw error; + } + } + + const updated = { ...current, [clientId]: applicationId }; + + if (!dryRun) { + await this.client.send( + new PutParameterCommand({ + Name: this.parameterName, + Value: JSON.stringify(updated), + Type: "SecureString", + Overwrite: true, + }), + ); + } + + return new Map(Object.entries(updated)); + } +} From 05263efff224133aff872d1e2bab7ee87bdda73e Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Tue, 24 Mar 2026 19:00:57 +0000 Subject: [PATCH 09/55] Target DLQ terminology fixes --- README.md | 2 +- .../terraform/components/callbacks/README.md | 94 +++++++++---------- tests/integration/dlq-redrive.test.ts | 2 +- .../integration/event-bus-to-webhook.test.ts | 2 +- 4 files changed, 48 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 605d7c16..7a2b387a 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The Client Callbacks infrastructure processes message and channel status events, - **Callbacks Event Bus**: Domain-specific EventBridge bus for webhook orchestration - **API Destination Target Rules**: Per-client rules invoking HTTPS endpoints with client-specific authentication - **Client Config Storage**: S3 bucket storing client subscription configurations (status filters, webhook endpoints) -- **Per-Client DLQs**: SQS Dead Letter Queues for failed webhook deliveries (one per client) +- **Per-Client Target DLQs**: SQS Dead Letter Queues for failed webhook deliveries (one per client target) ### Event Flow diff --git a/infrastructure/terraform/components/callbacks/README.md b/infrastructure/terraform/components/callbacks/README.md index 98425660..863bde34 100644 --- a/infrastructure/terraform/components/callbacks/README.md +++ b/infrastructure/terraform/components/callbacks/README.md @@ -4,61 +4,57 @@ ## Requirements -| Name | Version | -| ------------------------------------------------------------------------ | --------- | -| [terraform](#requirement_terraform) | >= 1.10.1 | -| [aws](#requirement_aws) | 6.13 | -| [random](#requirement_random) | ~> 3.0 | - +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.10.1 | +| [aws](#requirement\_aws) | 6.13 | +| [random](#requirement\_random) | ~> 3.0 | ## Inputs -| Name | Description | Type | Default | Required | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ------------- | :------: | -| [applications_map_parameter_name](#input_applications_map_parameter_name) | SSM Parameter Store path for the clientId-to-applicationData map, where applicationData is currently only the applicationId | `string` | `null` | no | -| [aws_account_id](#input_aws_account_id) | The AWS Account ID (numeric) | `string` | n/a | yes | -| [component](#input_component) | The variable encapsulating the name of this component | `string` | `"callbacks"` | no | -| [default_tags](#input_default_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | -| [deploy_mock_webhook](#input_deploy_mock_webhook) | Flag to deploy mock webhook lambda for integration testing (test/dev environments only) | `bool` | `false` | no | -| [enable_event_anomaly_detection](#input_enable_event_anomaly_detection) | Enable CloudWatch anomaly detection alarm for inbound event queue message reception | `bool` | `true` | no | -| [environment](#input_environment) | The name of the tfscaffold environment | `string` | n/a | yes | -| [event_anomaly_band_width](#input_event_anomaly_band_width) | The width of the anomaly detection band. Higher values (e.g. 4-6) reduce sensitivity and noise, lower values (e.g. 2-3) increase sensitivity. Recommended: 2-4. | `number` | `3` | no | -| [event_anomaly_evaluation_periods](#input_event_anomaly_evaluation_periods) | Number of evaluation periods for the anomaly alarm. Each period is defined by event_anomaly_period. | `number` | `2` | no | -| [event_anomaly_period](#input_event_anomaly_period) | The period in seconds over which the specified statistic is applied for anomaly detection. Minimum 300 seconds (5 minutes). Recommended: 300-600. | `number` | `300` | no | -| [force_lambda_code_deploy](#input_force_lambda_code_deploy) | If the lambda package in s3 has the same commit id tag as the terraform build branch, the lambda will not update automatically. Set to True if making changes to Lambda code from on the same commit for example during development | `bool` | `false` | no | -| [group](#input_group) | The group variables are being inherited from (often synonmous with account short-name) | `string` | n/a | yes | -| [kms_deletion_window](#input_kms_deletion_window) | When a kms key is deleted, how long should it wait in the pending deletion state? | `string` | `"30"` | no | -| [log_level](#input_log_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no | -| [log_retention_in_days](#input_log_retention_in_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no | -| [message_root_uri](#input_message_root_uri) | The root URI used for constructing message links in callback payloads | `string` | n/a | yes | -| [parent_acct_environment](#input_parent_acct_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no | -| [pipe_event_patterns](#input_pipe_event_patterns) | value | `list(string)` | `[]` | no | -| [pipe_log_level](#input_pipe_log_level) | Log level for the EventBridge Pipe. | `string` | `"ERROR"` | no | -| [pipe_sqs_input_batch_size](#input_pipe_sqs_input_batch_size) | n/a | `number` | `1` | no | -| [pipe_sqs_max_batch_window](#input_pipe_sqs_max_batch_window) | n/a | `number` | `2` | no | -| [project](#input_project) | The name of the tfscaffold project | `string` | n/a | yes | -| [region](#input_region) | The AWS Region | `string` | n/a | yes | -| [sqs_inbound_event_max_receive_count](#input_sqs_inbound_event_max_receive_count) | n/a | `number` | `3` | no | -| [sqs_inbound_event_visibility_timeout_seconds](#input_sqs_inbound_event_visibility_timeout_seconds) | n/a | `number` | `60` | no | - +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [applications\_map\_parameter\_name](#input\_applications\_map\_parameter\_name) | SSM Parameter Store path for the clientId-to-applicationData map, where applicationData is currently only the applicationId | `string` | `null` | no | +| [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes | +| [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"callbacks"` | no | +| [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | +| [deploy\_mock\_webhook](#input\_deploy\_mock\_webhook) | Flag to deploy mock webhook lambda for integration testing (test/dev environments only) | `bool` | `false` | no | +| [enable\_event\_anomaly\_detection](#input\_enable\_event\_anomaly\_detection) | Enable CloudWatch anomaly detection alarm for inbound event queue message reception | `bool` | `true` | no | +| [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes | +| [event\_anomaly\_band\_width](#input\_event\_anomaly\_band\_width) | The width of the anomaly detection band. Higher values (e.g. 4-6) reduce sensitivity and noise, lower values (e.g. 2-3) increase sensitivity. Recommended: 2-4. | `number` | `3` | no | +| [event\_anomaly\_evaluation\_periods](#input\_event\_anomaly\_evaluation\_periods) | Number of evaluation periods for the anomaly alarm. Each period is defined by event\_anomaly\_period. | `number` | `2` | no | +| [event\_anomaly\_period](#input\_event\_anomaly\_period) | The period in seconds over which the specified statistic is applied for anomaly detection. Minimum 300 seconds (5 minutes). Recommended: 300-600. | `number` | `300` | no | +| [force\_lambda\_code\_deploy](#input\_force\_lambda\_code\_deploy) | If the lambda package in s3 has the same commit id tag as the terraform build branch, the lambda will not update automatically. Set to True if making changes to Lambda code from on the same commit for example during development | `bool` | `false` | no | +| [group](#input\_group) | The group variables are being inherited from (often synonmous with account short-name) | `string` | n/a | yes | +| [kms\_deletion\_window](#input\_kms\_deletion\_window) | When a kms key is deleted, how long should it wait in the pending deletion state? | `string` | `"30"` | no | +| [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no | +| [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no | +| [message\_root\_uri](#input\_message\_root\_uri) | The root URI used for constructing message links in callback payloads | `string` | n/a | yes | +| [parent\_acct\_environment](#input\_parent\_acct\_environment) | Name of the environment responsible for the acct resources used, affects things like DNS zone. Useful for named dev environments | `string` | `"main"` | no | +| [pipe\_event\_patterns](#input\_pipe\_event\_patterns) | value | `list(string)` | `[]` | no | +| [pipe\_log\_level](#input\_pipe\_log\_level) | Log level for the EventBridge Pipe. | `string` | `"ERROR"` | no | +| [pipe\_sqs\_input\_batch\_size](#input\_pipe\_sqs\_input\_batch\_size) | n/a | `number` | `1` | no | +| [pipe\_sqs\_max\_batch\_window](#input\_pipe\_sqs\_max\_batch\_window) | n/a | `number` | `2` | no | +| [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | +| [region](#input\_region) | The AWS Region | `string` | n/a | yes | +| [sqs\_inbound\_event\_max\_receive\_count](#input\_sqs\_inbound\_event\_max\_receive\_count) | n/a | `number` | `3` | no | +| [sqs\_inbound\_event\_visibility\_timeout\_seconds](#input\_sqs\_inbound\_event\_visibility\_timeout\_seconds) | n/a | `number` | `60` | no | ## Modules -| Name | Source | Version | -| ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------- | -| [client_config_bucket](#module_client_config_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | -| [client_destination](#module_client_destination) | ../../modules/client-destination | n/a | -| [client_transform_filter_lambda](#module_client_transform_filter_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | -| [kms](#module_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-kms.zip | n/a | -| [mock_webhook_lambda](#module_mock_webhook_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | -| [sqs_inbound_event](#module_sqs_inbound_event) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a | - +| Name | Source | Version | +|------|--------|---------| +| [client\_config\_bucket](#module\_client\_config\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip | n/a | +| [client\_destination](#module\_client\_destination) | ../../modules/client-destination | n/a | +| [client\_transform\_filter\_lambda](#module\_client\_transform\_filter\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | +| [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-kms.zip | n/a | +| [mock\_webhook\_lambda](#module\_mock\_webhook\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | +| [sqs\_inbound\_event](#module\_sqs\_inbound\_event) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-sqs.zip | n/a | ## Outputs -| Name | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| [deployment](#output_deployment) | Deployment details used for post-deployment scripts | -| [mock_webhook_lambda_log_group_name](#output_mock_webhook_lambda_log_group_name) | CloudWatch log group name for mock webhook lambda (for integration test queries) | -| [mock_webhook_url](#output_mock_webhook_url) | URL endpoint for mock webhook (for TEST_WEBHOOK_URL environment variable) | - +| Name | Description | +|------|-------------| +| [deployment](#output\_deployment) | Deployment details used for post-deployment scripts | +| [mock\_webhook\_lambda\_log\_group\_name](#output\_mock\_webhook\_lambda\_log\_group\_name) | CloudWatch log group name for mock webhook lambda (for integration test queries) | +| [mock\_webhook\_url](#output\_mock\_webhook\_url) | URL endpoint for mock webhook (for TEST\_WEBHOOK\_URL environment variable) | diff --git a/tests/integration/dlq-redrive.test.ts b/tests/integration/dlq-redrive.test.ts index f0406004..b8eea77f 100644 --- a/tests/integration/dlq-redrive.test.ts +++ b/tests/integration/dlq-redrive.test.ts @@ -50,7 +50,7 @@ describe("DLQ Redrive", () => { }); describe("Infrastructure validation", () => { - it("should confirm the mock-client DLQ is accessible", async () => { + it("should confirm the target DLQ is accessible", async () => { const response = await sqsClient.send( new GetQueueAttributesCommand({ QueueUrl: dlqQueueUrl, diff --git a/tests/integration/event-bus-to-webhook.test.ts b/tests/integration/event-bus-to-webhook.test.ts index 0af0076c..56f2c82f 100644 --- a/tests/integration/event-bus-to-webhook.test.ts +++ b/tests/integration/event-bus-to-webhook.test.ts @@ -125,7 +125,7 @@ describe("SQS to Webhook Integration", () => { }); describe("Client Webhook DLQ", () => { - it("should route a non-retriable (4xx) webhook response to the per-client DLQ", async () => { + it("should route a non-retriable (4xx) webhook response to the per-target DLQ", async () => { const event: StatusPublishEvent = createMessageStatusPublishEvent({ data: { From 72967775e4df17362fbf46e8c73a1cf5072e94f7 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Tue, 24 Mar 2026 19:01:19 +0000 Subject: [PATCH 10/55] fixup! Config driven IT subscriptions --- tests/integration/helpers/seed-config.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 tests/integration/helpers/seed-config.ts diff --git a/tests/integration/helpers/seed-config.ts b/tests/integration/helpers/seed-config.ts new file mode 100644 index 00000000..95fe1c19 --- /dev/null +++ b/tests/integration/helpers/seed-config.ts @@ -0,0 +1,7 @@ +import seedConfigJson from "./mock-client-subscription.json"; + +export type SeedConfig = typeof seedConfigJson; + +export function getSeedConfig(): SeedConfig { + return seedConfigJson; +} From 17f648e53048342e277bfed8a436fbc63440db58 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Tue, 24 Mar 2026 19:14:03 +0000 Subject: [PATCH 11/55] Fix duplicate logging in webhook and add test for the logging that replaced it --- .../src/__tests__/index.test.ts | 37 ++++++------------- lambdas/mock-webhook-lambda/src/index.ts | 10 +---- 2 files changed, 12 insertions(+), 35 deletions(-) diff --git a/lambdas/mock-webhook-lambda/src/__tests__/index.test.ts b/lambdas/mock-webhook-lambda/src/__tests__/index.test.ts index 0cf93f1b..5ccef45c 100644 --- a/lambdas/mock-webhook-lambda/src/__tests__/index.test.ts +++ b/lambdas/mock-webhook-lambda/src/__tests__/index.test.ts @@ -328,7 +328,7 @@ describe("Mock Webhook Lambda", () => { }); describe("Logging", () => { - it("should log callback with structured format including messageId", async () => { + it("should log Callback received with structured context", async () => { const callback = { data: [ { @@ -347,40 +347,25 @@ describe("Mock Webhook Lambda", () => { ], }; - const event = createMockEvent(JSON.stringify(callback)); - await handler(event); - - const callbackCall = mockLogger.info.mock.calls.find( - ([message]: [string]) => - typeof message === "string" && message.startsWith("CALLBACK"), - ); - - expect(callbackCall).toBeDefined(); - const [message, context] = callbackCall as [ - string, - Record, - ]; - expect(message).toContain("some-idempotency-key"); - expect(message).toContain("MessageStatus"); - expect(context).toMatchObject({ - correlationId: "some-idempotency-key", - messageId: "test-msg-789", - messageType: "MessageStatus", + const event = createMockEvent(JSON.stringify(callback), { + ...DEFAULT_HEADERS, + "x-hmac-sha256-signature": "test-sig", }); + await handler(event); const receivedCall = mockLogger.info.mock.calls.find( ([msg]: [string]) => msg === "Callback received", ); + expect(receivedCall).toBeDefined(); - const [, receivedContext] = receivedCall as [ - string, - Record, - ]; - expect(receivedContext).toMatchObject({ + const [, context] = receivedCall as [string, Record]; + expect(context).toMatchObject({ + correlationId: "some-idempotency-key", messageId: "test-msg-789", callbackType: "MessageStatus", - signature: "", + signature: "test-sig", }); + expect(context).toHaveProperty("payload"); }); }); }); diff --git a/lambdas/mock-webhook-lambda/src/index.ts b/lambdas/mock-webhook-lambda/src/index.ts index fb46f13b..e72d1b36 100644 --- a/lambdas/mock-webhook-lambda/src/index.ts +++ b/lambdas/mock-webhook-lambda/src/index.ts @@ -119,16 +119,8 @@ async function buildResponse( }; } - logger.info( - `CALLBACK ${correlationId} ${item.type} : ${JSON.stringify(item)}`, - { - correlationId, - messageId, - messageType: item.type, - }, - ); - logger.info("Callback received", { + correlationId, messageId, callbackType: item.type, signature: headers["x-hmac-sha256-signature"] ?? "", From 8f9857558fb248c76f1779e7fbd7531a3b89c790 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Tue, 24 Mar 2026 19:52:31 +0000 Subject: [PATCH 12/55] Fix signing secret --- scripts/tests/integration.sh | 5 ++++- tests/integration/helpers/signature.ts | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index ffa0edcd..cf4f2ac3 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -8,6 +8,7 @@ npm ci SEED_CONFIG_FILE="$(pwd)/tests/integration/helpers/mock-client-subscription.json" CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") +APPLICATION_ID="some-application-id" FUNCTION_NAME="nhs-${ENVIRONMENT}-callbacks-mock-webhook" MOCK_WEBHOOK_URL=$(aws lambda get-function-url-config \ --function-name "${FUNCTION_NAME}" \ @@ -29,6 +30,8 @@ npm run clients:put -- \ --region eu-west-2 \ --json "${SEED_CONFIG_JSON}" -npm run applications-map:add -- --client-id "${CLIENT_ID}" --application-id some-application-id +npm run applications-map:add -- --client-id "${CLIENT_ID}" --application-id "${APPLICATION_ID}" + +export TEST_CALLBACK_SIGNING_SECRET="${TEST_CALLBACK_SIGNING_SECRET:-${APPLICATION_ID}.${MOCK_WEBHOOK_API_KEY}}" npm run test:integration diff --git a/tests/integration/helpers/signature.ts b/tests/integration/helpers/signature.ts index 2ed4d191..4c32f467 100644 --- a/tests/integration/helpers/signature.ts +++ b/tests/integration/helpers/signature.ts @@ -1,11 +1,20 @@ import { createHmac } from "node:crypto"; import type { CallbackItem } from "@nhs-notify-client-callbacks/models"; -const MOCK_HMAC_SECRET = "mock-application-id.some-api-key"; +function resolveSigningSecret(): string { + const result = process.env.TEST_CALLBACK_SIGNING_SECRET; + if (result) { + return result; + } + + throw new Error( + "Missing TEST_CALLBACK_SIGNING_SECRET for integration signature verification", + ); +} export function computeExpectedSignature(payload: CallbackItem): string { - // eslint-disable-next-line sonarjs/hardcoded-secret-signatures - return createHmac("sha256", MOCK_HMAC_SECRET) + const signingSecret = resolveSigningSecret(); + return createHmac("sha256", signingSecret) .update(JSON.stringify({ data: [payload] })) .digest("hex"); } From 99c27f61f91513a952e7d75e17d621684a2813eb Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Tue, 24 Mar 2026 20:46:45 +0000 Subject: [PATCH 13/55] Assert api key correctly --- lambdas/mock-webhook-lambda/src/index.ts | 1 + scripts/tests/integration.sh | 7 ++-- tests/integration/dlq-redrive.test.ts | 10 ++---- .../integration/event-bus-to-webhook.test.ts | 10 ++---- tests/integration/helpers/cloudwatch.ts | 11 +++++-- tests/integration/helpers/signature.ts | 32 +++++++++++++++++-- 6 files changed, 49 insertions(+), 22 deletions(-) diff --git a/lambdas/mock-webhook-lambda/src/index.ts b/lambdas/mock-webhook-lambda/src/index.ts index e72d1b36..0f820c74 100644 --- a/lambdas/mock-webhook-lambda/src/index.ts +++ b/lambdas/mock-webhook-lambda/src/index.ts @@ -123,6 +123,7 @@ async function buildResponse( correlationId, messageId, callbackType: item.type, + apiKey: headers["x-api-key"] ?? "", signature: headers["x-hmac-sha256-signature"] ?? "", payload: JSON.stringify(item), }); diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index cf4f2ac3..3888e178 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -8,7 +8,7 @@ npm ci SEED_CONFIG_FILE="$(pwd)/tests/integration/helpers/mock-client-subscription.json" CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") -APPLICATION_ID="some-application-id" +MOCK_APPLICATION_ID="some-application-id" FUNCTION_NAME="nhs-${ENVIRONMENT}-callbacks-mock-webhook" MOCK_WEBHOOK_URL=$(aws lambda get-function-url-config \ --function-name "${FUNCTION_NAME}" \ @@ -30,8 +30,9 @@ npm run clients:put -- \ --region eu-west-2 \ --json "${SEED_CONFIG_JSON}" -npm run applications-map:add -- --client-id "${CLIENT_ID}" --application-id "${APPLICATION_ID}" +npm run applications-map:add -- --client-id "${CLIENT_ID}" --application-id "${MOCK_APPLICATION_ID}" -export TEST_CALLBACK_SIGNING_SECRET="${TEST_CALLBACK_SIGNING_SECRET:-${APPLICATION_ID}.${MOCK_WEBHOOK_API_KEY}}" +export MOCK_WEBHOOK_API_KEY +export MOCK_APPLICATION_ID npm run test:integration diff --git a/tests/integration/dlq-redrive.test.ts b/tests/integration/dlq-redrive.test.ts index b8eea77f..9dbdd268 100644 --- a/tests/integration/dlq-redrive.test.ts +++ b/tests/integration/dlq-redrive.test.ts @@ -4,11 +4,11 @@ import type { StatusPublishEvent, } from "@nhs-notify-client-callbacks/models"; import { + assertCallbackHeaders, awaitSignedCallbacksFromWebhookLogGroup, buildInboundEventQueueUrl, buildLambdaLogGroupName, buildMockClientDlqQueueUrl, - computeExpectedSignature, createCloudWatchLogsClient, createMessageStatusPublishEvent, createSqsClient, @@ -103,9 +103,7 @@ describe("DLQ Redrive", () => { messageStatus: "delivered", }), }); - expect(callbacks[0].headers["x-hmac-sha256-signature"]).toBe( - computeExpectedSignature(callbacks[0].payload), - ); + assertCallbackHeaders(callbacks[0]); }, 120_000); it("should apply the same transformation logic to redriven events as original deliveries", async () => { @@ -168,9 +166,7 @@ describe("DLQ Redrive", () => { ).messageStatus, }), }); - expect(redriveCallbacks[0].headers["x-hmac-sha256-signature"]).toBe( - computeExpectedSignature(redriveCallbacks[0].payload), - ); + assertCallbackHeaders(redriveCallbacks[0]); }, 120_000); }); }); diff --git a/tests/integration/event-bus-to-webhook.test.ts b/tests/integration/event-bus-to-webhook.test.ts index 56f2c82f..2e6bc9dc 100644 --- a/tests/integration/event-bus-to-webhook.test.ts +++ b/tests/integration/event-bus-to-webhook.test.ts @@ -5,13 +5,13 @@ import { type StatusPublishEvent, } from "@nhs-notify-client-callbacks/models"; import { + assertCallbackHeaders, awaitQueueMessage, awaitQueueMessageByMessageId, buildInboundEventDlqQueueUrl, buildInboundEventQueueUrl, buildLambdaLogGroupName, buildMockClientDlqQueueUrl, - computeExpectedSignature, createChannelStatusPublishEvent, createCloudWatchLogsClient, createMessageStatusPublishEvent, @@ -87,9 +87,7 @@ describe("SQS to Webhook Integration", () => { }), }); - expect(callbacks[0].headers["x-hmac-sha256-signature"]).toBe( - computeExpectedSignature(callbacks[0].payload), - ); + assertCallbackHeaders(callbacks[0]); }, 120_000); }); @@ -118,9 +116,7 @@ describe("SQS to Webhook Integration", () => { }), }); - expect(callbacks[0].headers["x-hmac-sha256-signature"]).toBe( - computeExpectedSignature(callbacks[0].payload), - ); + assertCallbackHeaders(callbacks[0]); }, 120_000); }); diff --git a/tests/integration/helpers/cloudwatch.ts b/tests/integration/helpers/cloudwatch.ts index 84559f1a..d308c96d 100644 --- a/tests/integration/helpers/cloudwatch.ts +++ b/tests/integration/helpers/cloudwatch.ts @@ -15,13 +15,17 @@ type LogEntry = { correlationId?: string; callbackType?: string; clientId?: string; + apiKey?: string; signature?: string; payload?: string; }; export type SignedCallback = { payload: CallbackItem; - headers: { "x-hmac-sha256-signature": string }; + headers: { + "x-api-key": string; + "x-hmac-sha256-signature": string; + }; }; async function querySignedCallbacksFromWebhookLogGroup( @@ -51,7 +55,10 @@ async function querySignedCallbacksFromWebhookLogGroup( if (entry.signature !== undefined && entry.payload) { callbacks.push({ payload: JSON.parse(entry.payload) as CallbackItem, - headers: { "x-hmac-sha256-signature": entry.signature }, + headers: { + "x-api-key": entry.apiKey ?? "", + "x-hmac-sha256-signature": entry.signature, + }, }); } } catch { diff --git a/tests/integration/helpers/signature.ts b/tests/integration/helpers/signature.ts index 4c32f467..e32c90f5 100644 --- a/tests/integration/helpers/signature.ts +++ b/tests/integration/helpers/signature.ts @@ -1,17 +1,33 @@ import { createHmac } from "node:crypto"; import type { CallbackItem } from "@nhs-notify-client-callbacks/models"; +import type { SignedCallback } from "./cloudwatch"; -function resolveSigningSecret(): string { - const result = process.env.TEST_CALLBACK_SIGNING_SECRET; +function resolveMockWebhookApiKey(): string { + const result = process.env.MOCK_WEBHOOK_API_KEY; if (result) { return result; } throw new Error( - "Missing TEST_CALLBACK_SIGNING_SECRET for integration signature verification", + "Missing MOCK_WEBHOOK_API_KEY for integration signature verification", ); } +function resolveApplicationId(): string { + const result = process.env.MOCK_APPLICATION_ID; + if (result) { + return result; + } + + throw new Error( + "Missing MOCK_APPLICATION_ID for integration signature verification", + ); +} + +function resolveSigningSecret(): string { + return `${resolveApplicationId()}.${resolveMockWebhookApiKey()}`; +} + export function computeExpectedSignature(payload: CallbackItem): string { const signingSecret = resolveSigningSecret(); return createHmac("sha256", signingSecret) @@ -19,4 +35,14 @@ export function computeExpectedSignature(payload: CallbackItem): string { .digest("hex"); } +export function assertCallbackHeaders(callback: SignedCallback): void { + expect(callback.headers["x-api-key"]).toBeDefined(); + // check the x-api-key that was received matches the 1 in the config + // there could be a discrepancy between the api-key on the API destination and the api-key in the config + expect(callback.headers["x-api-key"]).toBe(process.env.MOCK_APPLICATION_ID); + expect(callback.headers["x-hmac-sha256-signature"]).toBe( + computeExpectedSignature(callback.payload), + ); +} + export default computeExpectedSignature; From 6d72254ad33a791909e0f8f0aad4267226671aaf Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Tue, 24 Mar 2026 21:28:39 +0000 Subject: [PATCH 14/55] fixup! Assert api key correctly --- tests/integration/helpers/signature.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/helpers/signature.ts b/tests/integration/helpers/signature.ts index e32c90f5..24079140 100644 --- a/tests/integration/helpers/signature.ts +++ b/tests/integration/helpers/signature.ts @@ -39,7 +39,7 @@ export function assertCallbackHeaders(callback: SignedCallback): void { expect(callback.headers["x-api-key"]).toBeDefined(); // check the x-api-key that was received matches the 1 in the config // there could be a discrepancy between the api-key on the API destination and the api-key in the config - expect(callback.headers["x-api-key"]).toBe(process.env.MOCK_APPLICATION_ID); + expect(callback.headers["x-api-key"]).toBe(process.env.MOCK_WEBHOOK_API_KEY); expect(callback.headers["x-hmac-sha256-signature"]).toBe( computeExpectedSignature(callback.payload), ); From a0198e81515285379928b2b1ad4c0e8a47da950e Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 09:40:43 +0000 Subject: [PATCH 15/55] Attempt terraform re-apply --- .github/actions/acceptance-tests/action.yaml | 11 ++++++++--- scripts/tests/integration.sh | 13 +++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml index 92fb879c..aca9959e 100644 --- a/.github/actions/acceptance-tests/action.yaml +++ b/.github/actions/acceptance-tests/action.yaml @@ -41,14 +41,19 @@ runs: - name: "Set environment variables" shell: bash + env: + TARGET_ENVIRONMENT: ${{ inputs.targetEnvironment }} + TARGET_ACCOUNT_GROUP: ${{ inputs.targetAccountGroup }} run: | - echo "PR_NUMBER=${{ inputs.targetEnvironment }}" >> $GITHUB_ENV - echo "ENVIRONMENT=${{ inputs.targetEnvironment }}" >> $GITHUB_ENV + echo "PR_NUMBER=${TARGET_ENVIRONMENT}" >> $GITHUB_ENV + echo "ENVIRONMENT=${TARGET_ENVIRONMENT}" >> $GITHUB_ENV + echo "GROUP=${TARGET_ACCOUNT_GROUP}" >> $GITHUB_ENV - name: Run test - ${{ inputs.testType }} shell: bash env: PROJECT: nhs COMPONENT: ${{ inputs.targetComponent }} + TEST_TYPE: ${{ inputs.testType }} run: | - make test-${{ inputs.testType }} + make "test-${TEST_TYPE}" diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index 3888e178..2decac8a 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -35,4 +35,17 @@ npm run applications-map:add -- --client-id "${CLIENT_ID}" --application-id "${M export MOCK_WEBHOOK_API_KEY export MOCK_APPLICATION_ID +# Re-apply terraform so it picks up the seeded client config and creates API destinations +echo "Re-applying terraform to create infrastructure for seeded client configuration..." +cd infrastructure/terraform +./bin/terraform.sh \ + --project "${PROJECT}" \ + --region "${AWS_REGION:-eu-west-2}" \ + --component "${COMPONENT}" \ + --environment "${ENVIRONMENT}" \ + --group "${GROUP}" \ + --build-id "${GROUP}-${ENVIRONMENT}" \ + --action apply +cd "$(git rev-parse --show-toplevel)" + npm run test:integration From 69d8c9a00567587625a2f9be4f2e251344fe2fee Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 09:58:08 +0000 Subject: [PATCH 16/55] fixup! Attempt terraform re-apply --- .github/actions/acceptance-tests/action.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml index aca9959e..ff348936 100644 --- a/.github/actions/acceptance-tests/action.yaml +++ b/.github/actions/acceptance-tests/action.yaml @@ -39,6 +39,9 @@ runs: with: GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} + - name: Setup ASDF + uses: asdf-vm/actions/setup@1902764435ca0dd2f3388eea723a4f92a4eb8302 + - name: "Set environment variables" shell: bash env: From 8cb566a222b8cc5adb519e902a27159c15c0b31e Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 10:15:29 +0000 Subject: [PATCH 17/55] 2nd mock client --- scripts/tests/integration.sh | 30 ++++++++++++- .../helpers/mock-client-subscription.json | 14 +++++- .../mock-it-client-2-subscription.json | 45 +++++++++++++++++++ tests/integration/helpers/seed-config.ts | 6 +++ 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 tests/integration/helpers/mock-it-client-2-subscription.json diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index 2decac8a..678ee302 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -7,8 +7,11 @@ cd "$(git rev-parse --show-toplevel)" npm ci SEED_CONFIG_FILE="$(pwd)/tests/integration/helpers/mock-client-subscription.json" +SEED_CONFIG_FILE_2="$(pwd)/tests/integration/helpers/mock-it-client-2-subscription.json" CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") +CLIENT_ID_2=$(jq -r '.clientId' "${SEED_CONFIG_FILE_2}") MOCK_APPLICATION_ID="some-application-id" +MOCK_APPLICATION_ID_2="some-application-id-2" FUNCTION_NAME="nhs-${ENVIRONMENT}-callbacks-mock-webhook" MOCK_WEBHOOK_URL=$(aws lambda get-function-url-config \ --function-name "${FUNCTION_NAME}" \ @@ -18,22 +21,47 @@ MOCK_WEBHOOK_API_KEY=$(aws lambda get-function-configuration \ --function-name "${FUNCTION_NAME}" \ --region eu-west-2 \ --query 'Environment.Variables.API_KEY' --output text) + +# Inject URL (with path suffix per target) and API key into seed config 1 SEED_CONFIG_JSON=$(jq \ --arg url "${MOCK_WEBHOOK_URL}" \ --arg key "${MOCK_WEBHOOK_API_KEY}" \ - '.targets[0].invocationEndpoint = $url | .targets[0].apiKey.headerValue = $key' \ + ' + .targets[0].invocationEndpoint = ($url + "client-1-target-1") | + .targets[0].apiKey.headerValue = $key | + .targets[1].invocationEndpoint = ($url + "client-1-target-2") | + .targets[1].apiKey.headerValue = $key + ' \ "${SEED_CONFIG_FILE}") +# Inject URL (with path suffix per target) and API key into seed config 2 +SEED_CONFIG_JSON_2=$(jq \ + --arg url "${MOCK_WEBHOOK_URL}" \ + --arg key "${MOCK_WEBHOOK_API_KEY}" \ + ' + .targets[0].invocationEndpoint = ($url + "client-2-target-1") | + .targets[0].apiKey.headerValue = $key + ' \ + "${SEED_CONFIG_FILE_2}") + npm run clients:put -- \ --client-id "${CLIENT_ID}" \ --environment "${ENVIRONMENT}" \ --region eu-west-2 \ --json "${SEED_CONFIG_JSON}" +npm run clients:put -- \ + --client-id "${CLIENT_ID_2}" \ + --environment "${ENVIRONMENT}" \ + --region eu-west-2 \ + --json "${SEED_CONFIG_JSON_2}" + npm run applications-map:add -- --client-id "${CLIENT_ID}" --application-id "${MOCK_APPLICATION_ID}" +npm run applications-map:add -- --client-id "${CLIENT_ID_2}" --application-id "${MOCK_APPLICATION_ID_2}" export MOCK_WEBHOOK_API_KEY export MOCK_APPLICATION_ID +export MOCK_APPLICATION_ID_2 # Re-apply terraform so it picks up the seeded client config and creates API destinations echo "Re-applying terraform to create infrastructure for seeded client configuration..." diff --git a/tests/integration/helpers/mock-client-subscription.json b/tests/integration/helpers/mock-client-subscription.json index 23a9c035..ce684e0c 100644 --- a/tests/integration/helpers/mock-client-subscription.json +++ b/tests/integration/helpers/mock-client-subscription.json @@ -9,7 +9,8 @@ "subscriptionId": "sub-28fc741d-de6c-41a9-8fb0-89c4115c7dcf", "subscriptionType": "MessageStatus", "targetIds": [ - "target-23b2ee2f-8e81-43cd-9bb8-5ea30a09f779" + "target-23b2ee2f-8e81-43cd-9bb8-5ea30a09f779", + "target-b4c5d6e7-f8a9-0b1c-2d3e-f4a5b6c7d8e9" ] }, { @@ -40,6 +41,17 @@ "invocationRateLimit": 10, "targetId": "target-23b2ee2f-8e81-43cd-9bb8-5ea30a09f779", "type": "API" + }, + { + "apiKey": { + "headerName": "x-api-key", + "headerValue": "REPLACED_BY_SCRIPT" + }, + "invocationEndpoint": "https://REPLACED_BY_SCRIPT", + "invocationMethod": "POST", + "invocationRateLimit": 10, + "targetId": "target-b4c5d6e7-f8a9-0b1c-2d3e-f4a5b6c7d8e9", + "type": "API" } ] } diff --git a/tests/integration/helpers/mock-it-client-2-subscription.json b/tests/integration/helpers/mock-it-client-2-subscription.json new file mode 100644 index 00000000..4d981ad4 --- /dev/null +++ b/tests/integration/helpers/mock-it-client-2-subscription.json @@ -0,0 +1,45 @@ +{ + "clientId": "mock-it-client-2", + "subscriptions": [ + { + "messageStatuses": [ + "DELIVERED", + "FAILED" + ], + "subscriptionId": "sub-11223344-5566-7788-99aa-bbccddeeff00", + "subscriptionType": "MessageStatus", + "targetIds": [ + "target-c1d2e3f4-a5b6-7c8d-9e0f-1a2b3c4d5e6f" + ] + }, + { + "channelStatuses": [ + "DELIVERED", + "FAILED" + ], + "channelType": "NHSAPP", + "subscriptionId": "sub-aabbccdd-eeff-0011-2233-445566778899", + "subscriptionType": "ChannelStatus", + "supplierStatuses": [ + "delivered", + "permanent_failure" + ], + "targetIds": [ + "target-c1d2e3f4-a5b6-7c8d-9e0f-1a2b3c4d5e6f" + ] + } + ], + "targets": [ + { + "apiKey": { + "headerName": "x-api-key", + "headerValue": "REPLACED_BY_SCRIPT" + }, + "invocationEndpoint": "https://REPLACED_BY_SCRIPT", + "invocationMethod": "POST", + "invocationRateLimit": 10, + "targetId": "target-c1d2e3f4-a5b6-7c8d-9e0f-1a2b3c4d5e6f", + "type": "API" + } + ] +} diff --git a/tests/integration/helpers/seed-config.ts b/tests/integration/helpers/seed-config.ts index 95fe1c19..a5b541d0 100644 --- a/tests/integration/helpers/seed-config.ts +++ b/tests/integration/helpers/seed-config.ts @@ -1,7 +1,13 @@ import seedConfigJson from "./mock-client-subscription.json"; +import seedConfig2Json from "./mock-it-client-2-subscription.json"; export type SeedConfig = typeof seedConfigJson; +export type SeedConfig2 = typeof seedConfig2Json; export function getSeedConfig(): SeedConfig { return seedConfigJson; } + +export function getSeedConfig2(): SeedConfig2 { + return seedConfig2Json; +} From 724a2f80f278e9e61254a729346da4c01fedd988 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 10:26:32 +0000 Subject: [PATCH 18/55] Feedback: remove unncessary signature input_transformer --- .../client-destination/cloudwatch_event_rule_main.tf | 3 +-- lambdas/mock-webhook-lambda/src/index.ts | 10 +++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf b/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf index 18d1eb03..bdf7ea47 100644 --- a/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf +++ b/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf @@ -27,8 +27,7 @@ resource "aws_cloudwatch_event_target" "per_subscription_target" { input_transformer { input_paths = { - signature = "$.detail.signatures.${replace(each.value.target_id, "-", "_")}" - data = "$.detail.payload.data" + data = "$.detail.payload.data" } input_template = "{\"data\": }" diff --git a/lambdas/mock-webhook-lambda/src/index.ts b/lambdas/mock-webhook-lambda/src/index.ts index 0f820c74..3d74a86a 100644 --- a/lambdas/mock-webhook-lambda/src/index.ts +++ b/lambdas/mock-webhook-lambda/src/index.ts @@ -40,6 +40,9 @@ async function buildResponse( rawPath?: string; requestContext?: { http?: { method?: string } }; }; + const headers = Object.fromEntries( + Object.entries(event.headers).map(([k, v]) => [k.toLowerCase(), v]), + ) as Record; logger.info("Mock webhook invoked", { path: event.path ?? eventWithFunctionUrlFields.rawPath, @@ -47,14 +50,11 @@ async function buildResponse( event.httpMethod ?? eventWithFunctionUrlFields.requestContext?.http?.method, hasBody: Boolean(event.body), - headers: event.headers, + "x-api-key": headers["x-api-key"], + "x-hmac-sha256-signature": headers["x-hmac-sha256-signature"], payload: event.body, }); - const headers = Object.fromEntries( - Object.entries(event.headers).map(([k, v]) => [k.toLowerCase(), v]), - ) as Record; - const expectedApiKey = process.env.API_KEY; const providedApiKey = headers["x-api-key"]; From 9e03a523a97e967591666f82cb17be79858f31db Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 10:31:44 +0000 Subject: [PATCH 19/55] fixup! Attempt terraform re-apply --- scripts/tests/integration.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index 678ee302..ae62598b 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -63,7 +63,9 @@ export MOCK_WEBHOOK_API_KEY export MOCK_APPLICATION_ID export MOCK_APPLICATION_ID_2 -# Re-apply terraform so it picks up the seeded client config and creates API destinations +# Re-apply terraform so it picks up the seeded client config and creates API destinations. +# Omit --build-id so tfscaffold does a fresh plan+apply rather than using the stale +# plan file from the initial deploy. echo "Re-applying terraform to create infrastructure for seeded client configuration..." cd infrastructure/terraform ./bin/terraform.sh \ @@ -72,7 +74,6 @@ cd infrastructure/terraform --component "${COMPONENT}" \ --environment "${ENVIRONMENT}" \ --group "${GROUP}" \ - --build-id "${GROUP}-${ENVIRONMENT}" \ --action apply cd "$(git rev-parse --show-toplevel)" From 6c8e74be8de7a62e98e5c8186eb7a8179f27c37c Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 10:52:56 +0000 Subject: [PATCH 20/55] fixup! Attempt terraform re-apply --- .github/actions/acceptance-tests/action.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml index ff348936..759c1791 100644 --- a/.github/actions/acceptance-tests/action.yaml +++ b/.github/actions/acceptance-tests/action.yaml @@ -42,6 +42,18 @@ runs: - name: Setup ASDF uses: asdf-vm/actions/setup@1902764435ca0dd2f3388eea723a4f92a4eb8302 + - name: "Fetch terraform tfvars from nhs-notify-internal" + uses: actions/checkout@v6 + with: + sparse-checkout: infrastructure/terraform/etc + path: _internal + + - name: "Map terraform config for re-apply" + shell: bash + run: | + cp -r _internal/infrastructure/terraform/etc infrastructure/terraform/ + rm -rf _internal + - name: "Set environment variables" shell: bash env: From d029cb2e7ffbd2cb43ece4cc23bb74c93971d347 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 10:54:28 +0000 Subject: [PATCH 21/55] Revert "2nd mock client" This reverts commit aff23f0635b787361c19196293bb83ac89081d84. --- scripts/tests/integration.sh | 30 +------------ .../helpers/mock-client-subscription.json | 14 +----- .../mock-it-client-2-subscription.json | 45 ------------------- tests/integration/helpers/seed-config.ts | 6 --- 4 files changed, 2 insertions(+), 93 deletions(-) delete mode 100644 tests/integration/helpers/mock-it-client-2-subscription.json diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index ae62598b..679012d9 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -7,11 +7,8 @@ cd "$(git rev-parse --show-toplevel)" npm ci SEED_CONFIG_FILE="$(pwd)/tests/integration/helpers/mock-client-subscription.json" -SEED_CONFIG_FILE_2="$(pwd)/tests/integration/helpers/mock-it-client-2-subscription.json" CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") -CLIENT_ID_2=$(jq -r '.clientId' "${SEED_CONFIG_FILE_2}") MOCK_APPLICATION_ID="some-application-id" -MOCK_APPLICATION_ID_2="some-application-id-2" FUNCTION_NAME="nhs-${ENVIRONMENT}-callbacks-mock-webhook" MOCK_WEBHOOK_URL=$(aws lambda get-function-url-config \ --function-name "${FUNCTION_NAME}" \ @@ -21,47 +18,22 @@ MOCK_WEBHOOK_API_KEY=$(aws lambda get-function-configuration \ --function-name "${FUNCTION_NAME}" \ --region eu-west-2 \ --query 'Environment.Variables.API_KEY' --output text) - -# Inject URL (with path suffix per target) and API key into seed config 1 SEED_CONFIG_JSON=$(jq \ --arg url "${MOCK_WEBHOOK_URL}" \ --arg key "${MOCK_WEBHOOK_API_KEY}" \ - ' - .targets[0].invocationEndpoint = ($url + "client-1-target-1") | - .targets[0].apiKey.headerValue = $key | - .targets[1].invocationEndpoint = ($url + "client-1-target-2") | - .targets[1].apiKey.headerValue = $key - ' \ + '.targets[0].invocationEndpoint = $url | .targets[0].apiKey.headerValue = $key' \ "${SEED_CONFIG_FILE}") -# Inject URL (with path suffix per target) and API key into seed config 2 -SEED_CONFIG_JSON_2=$(jq \ - --arg url "${MOCK_WEBHOOK_URL}" \ - --arg key "${MOCK_WEBHOOK_API_KEY}" \ - ' - .targets[0].invocationEndpoint = ($url + "client-2-target-1") | - .targets[0].apiKey.headerValue = $key - ' \ - "${SEED_CONFIG_FILE_2}") - npm run clients:put -- \ --client-id "${CLIENT_ID}" \ --environment "${ENVIRONMENT}" \ --region eu-west-2 \ --json "${SEED_CONFIG_JSON}" -npm run clients:put -- \ - --client-id "${CLIENT_ID_2}" \ - --environment "${ENVIRONMENT}" \ - --region eu-west-2 \ - --json "${SEED_CONFIG_JSON_2}" - npm run applications-map:add -- --client-id "${CLIENT_ID}" --application-id "${MOCK_APPLICATION_ID}" -npm run applications-map:add -- --client-id "${CLIENT_ID_2}" --application-id "${MOCK_APPLICATION_ID_2}" export MOCK_WEBHOOK_API_KEY export MOCK_APPLICATION_ID -export MOCK_APPLICATION_ID_2 # Re-apply terraform so it picks up the seeded client config and creates API destinations. # Omit --build-id so tfscaffold does a fresh plan+apply rather than using the stale diff --git a/tests/integration/helpers/mock-client-subscription.json b/tests/integration/helpers/mock-client-subscription.json index ce684e0c..23a9c035 100644 --- a/tests/integration/helpers/mock-client-subscription.json +++ b/tests/integration/helpers/mock-client-subscription.json @@ -9,8 +9,7 @@ "subscriptionId": "sub-28fc741d-de6c-41a9-8fb0-89c4115c7dcf", "subscriptionType": "MessageStatus", "targetIds": [ - "target-23b2ee2f-8e81-43cd-9bb8-5ea30a09f779", - "target-b4c5d6e7-f8a9-0b1c-2d3e-f4a5b6c7d8e9" + "target-23b2ee2f-8e81-43cd-9bb8-5ea30a09f779" ] }, { @@ -41,17 +40,6 @@ "invocationRateLimit": 10, "targetId": "target-23b2ee2f-8e81-43cd-9bb8-5ea30a09f779", "type": "API" - }, - { - "apiKey": { - "headerName": "x-api-key", - "headerValue": "REPLACED_BY_SCRIPT" - }, - "invocationEndpoint": "https://REPLACED_BY_SCRIPT", - "invocationMethod": "POST", - "invocationRateLimit": 10, - "targetId": "target-b4c5d6e7-f8a9-0b1c-2d3e-f4a5b6c7d8e9", - "type": "API" } ] } diff --git a/tests/integration/helpers/mock-it-client-2-subscription.json b/tests/integration/helpers/mock-it-client-2-subscription.json deleted file mode 100644 index 4d981ad4..00000000 --- a/tests/integration/helpers/mock-it-client-2-subscription.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "clientId": "mock-it-client-2", - "subscriptions": [ - { - "messageStatuses": [ - "DELIVERED", - "FAILED" - ], - "subscriptionId": "sub-11223344-5566-7788-99aa-bbccddeeff00", - "subscriptionType": "MessageStatus", - "targetIds": [ - "target-c1d2e3f4-a5b6-7c8d-9e0f-1a2b3c4d5e6f" - ] - }, - { - "channelStatuses": [ - "DELIVERED", - "FAILED" - ], - "channelType": "NHSAPP", - "subscriptionId": "sub-aabbccdd-eeff-0011-2233-445566778899", - "subscriptionType": "ChannelStatus", - "supplierStatuses": [ - "delivered", - "permanent_failure" - ], - "targetIds": [ - "target-c1d2e3f4-a5b6-7c8d-9e0f-1a2b3c4d5e6f" - ] - } - ], - "targets": [ - { - "apiKey": { - "headerName": "x-api-key", - "headerValue": "REPLACED_BY_SCRIPT" - }, - "invocationEndpoint": "https://REPLACED_BY_SCRIPT", - "invocationMethod": "POST", - "invocationRateLimit": 10, - "targetId": "target-c1d2e3f4-a5b6-7c8d-9e0f-1a2b3c4d5e6f", - "type": "API" - } - ] -} diff --git a/tests/integration/helpers/seed-config.ts b/tests/integration/helpers/seed-config.ts index a5b541d0..95fe1c19 100644 --- a/tests/integration/helpers/seed-config.ts +++ b/tests/integration/helpers/seed-config.ts @@ -1,13 +1,7 @@ import seedConfigJson from "./mock-client-subscription.json"; -import seedConfig2Json from "./mock-it-client-2-subscription.json"; export type SeedConfig = typeof seedConfigJson; -export type SeedConfig2 = typeof seedConfig2Json; export function getSeedConfig(): SeedConfig { return seedConfigJson; } - -export function getSeedConfig2(): SeedConfig2 { - return seedConfig2Json; -} From c5709e48945b620bbb6a56e317bd6c066c2900c2 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 10:57:46 +0000 Subject: [PATCH 22/55] Feedback: improve application map call --- scripts/tests/integration.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index 679012d9..aec2d57f 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -30,7 +30,11 @@ npm run clients:put -- \ --region eu-west-2 \ --json "${SEED_CONFIG_JSON}" -npm run applications-map:add -- --client-id "${CLIENT_ID}" --application-id "${MOCK_APPLICATION_ID}" +npm run applications-map:add -- \ + --client-id "${CLIENT_ID}" \ + --application-id "${MOCK_APPLICATION_ID}" \ + --environment "${ENVIRONMENT}" \ + --region eu-west-2 export MOCK_WEBHOOK_API_KEY export MOCK_APPLICATION_ID From 55ba57590a9a22d2f431c3fde34c7137b033d71c Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 11:20:10 +0000 Subject: [PATCH 23/55] fixup! Attempt terraform re-apply --- scripts/tests/integration.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index aec2d57f..7ffa7ef4 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -44,6 +44,14 @@ export MOCK_APPLICATION_ID # plan file from the initial deploy. echo "Re-applying terraform to create infrastructure for seeded client configuration..." cd infrastructure/terraform + +# Generate dynamic environment tfvars if not already present (PR/dynamic envs) +TF_ENV_FILE="etc/env_${AWS_REGION:-eu-west-2}_${ENVIRONMENT}.tfvars" +if [ ! -f "${TF_ENV_FILE}" ] && [ -f "etc/_dynamic_env.tfvars.tpl" ]; then + cp "etc/_dynamic_env.tfvars.tpl" "${TF_ENV_FILE}" + echo "environment = \"${ENVIRONMENT}\"" >> "${TF_ENV_FILE}" +fi + ./bin/terraform.sh \ --project "${PROJECT}" \ --region "${AWS_REGION:-eu-west-2}" \ From 7f45666c915eb6d39a99ea97e4489535dedee4df Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 11:35:06 +0000 Subject: [PATCH 24/55] Rename mock-client -> mock-it-client --- scripts/tests/integration.sh | 8 ++++---- tests/integration/helpers/event-factories.ts | 6 +++--- ...subscription.json => mock-it-client-subscription.json} | 2 +- tests/integration/helpers/seed-config.ts | 6 +++--- tests/integration/helpers/sqs.ts | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) rename tests/integration/helpers/{mock-client-subscription.json => mock-it-client-subscription.json} (96%) diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index 7ffa7ef4..865be531 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -6,8 +6,8 @@ cd "$(git rev-parse --show-toplevel)" npm ci -SEED_CONFIG_FILE="$(pwd)/tests/integration/helpers/mock-client-subscription.json" -CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") +SEED_CONFIG_FILE="$(pwd)/tests/integration/helpers/mock-it-client-subscription.json" +MOCK_IT_CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") MOCK_APPLICATION_ID="some-application-id" FUNCTION_NAME="nhs-${ENVIRONMENT}-callbacks-mock-webhook" MOCK_WEBHOOK_URL=$(aws lambda get-function-url-config \ @@ -25,13 +25,13 @@ SEED_CONFIG_JSON=$(jq \ "${SEED_CONFIG_FILE}") npm run clients:put -- \ - --client-id "${CLIENT_ID}" \ + --client-id "${MOCK_IT_CLIENT_ID}" \ --environment "${ENVIRONMENT}" \ --region eu-west-2 \ --json "${SEED_CONFIG_JSON}" npm run applications-map:add -- \ - --client-id "${CLIENT_ID}" \ + --client-id "${MOCK_IT_CLIENT_ID}" \ --application-id "${MOCK_APPLICATION_ID}" \ --environment "${ENVIRONMENT}" \ --region eu-west-2 diff --git a/tests/integration/helpers/event-factories.ts b/tests/integration/helpers/event-factories.ts index bded4759..807b9b40 100644 --- a/tests/integration/helpers/event-factories.ts +++ b/tests/integration/helpers/event-factories.ts @@ -5,7 +5,7 @@ import type { } from "@nhs-notify-client-callbacks/models"; import { EventTypes } from "@nhs-notify-client-callbacks/models"; -import { getSeedConfig } from "./seed-config"; +import { getMockItClientConfig } from "./seed-config"; type MessageEventOverrides = { event?: Partial>; @@ -25,7 +25,7 @@ export function createMessageStatusPublishEvent( overrides?.data?.messageReference ?? `ref-${crypto.randomUUID()}`; const baseData: MessageStatusData = { - clientId: getSeedConfig().clientId, + clientId: getMockItClientConfig().clientId, messageId, messageReference, messageStatus: "DELIVERED", @@ -80,7 +80,7 @@ export function createChannelStatusPublishEvent( overrides?.data?.messageReference ?? `ref-${crypto.randomUUID()}`; const baseData: ChannelStatusData = { - clientId: getSeedConfig().clientId, + clientId: getMockItClientConfig().clientId, messageId, messageReference, channel: "NHSAPP", diff --git a/tests/integration/helpers/mock-client-subscription.json b/tests/integration/helpers/mock-it-client-subscription.json similarity index 96% rename from tests/integration/helpers/mock-client-subscription.json rename to tests/integration/helpers/mock-it-client-subscription.json index 23a9c035..592c97b6 100644 --- a/tests/integration/helpers/mock-client-subscription.json +++ b/tests/integration/helpers/mock-it-client-subscription.json @@ -1,5 +1,5 @@ { - "clientId": "mock-seed-client", + "clientId": "mock-it-client", "subscriptions": [ { "messageStatuses": [ diff --git a/tests/integration/helpers/seed-config.ts b/tests/integration/helpers/seed-config.ts index 95fe1c19..39fe2bfc 100644 --- a/tests/integration/helpers/seed-config.ts +++ b/tests/integration/helpers/seed-config.ts @@ -1,7 +1,7 @@ -import seedConfigJson from "./mock-client-subscription.json"; +import seedConfigJson from "./mock-it-client-subscription.json"; -export type SeedConfig = typeof seedConfigJson; +export type MockItClientConfig = typeof seedConfigJson; -export function getSeedConfig(): SeedConfig { +export function getMockItClientConfig(): MockItClientConfig { return seedConfigJson; } diff --git a/tests/integration/helpers/sqs.ts b/tests/integration/helpers/sqs.ts index d0970aa1..415c33bb 100644 --- a/tests/integration/helpers/sqs.ts +++ b/tests/integration/helpers/sqs.ts @@ -12,7 +12,7 @@ import { logger } from "@nhs-notify-client-callbacks/logger"; import { waitUntil } from "async-wait-until"; import type { DeploymentDetails } from "./deployment"; -import { getSeedConfig } from "./seed-config"; +import { getMockItClientConfig } from "./seed-config"; const QUEUE_WAIT_TIMEOUT_MS = 60_000; const POLL_INTERVAL_MS = 500; @@ -63,7 +63,7 @@ export function buildInboundEventDlqQueueUrl( export function buildMockClientDlqQueueUrl( deploymentDetails: DeploymentDetails, ): string { - const { targets } = getSeedConfig(); + const { targets } = getMockItClientConfig(); return buildQueueUrl(deploymentDetails, `${targets[0].targetId}-dlq`); } From 02262d0cfa9a1fd4593690d670262e4593febe86 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 12:49:32 +0000 Subject: [PATCH 25/55] fixup! Attempt terraform re-apply --- .github/actions/acceptance-tests/action.yaml | 78 +++++++++++++++++--- scripts/tests/integration.sh | 57 -------------- scripts/tests/seed-integration-config.sh | 40 ++++++++++ 3 files changed, 107 insertions(+), 68 deletions(-) create mode 100755 scripts/tests/seed-integration-config.sh diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml index 759c1791..469bc716 100644 --- a/.github/actions/acceptance-tests/action.yaml +++ b/.github/actions/acceptance-tests/action.yaml @@ -19,6 +19,11 @@ inputs: description: Name of the component under test required: true + iamRoleName: + description: IAM role name for terraform operations + required: false + default: "" + runs: using: "composite" @@ -39,30 +44,81 @@ runs: with: GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} - - name: Setup ASDF - uses: asdf-vm/actions/setup@1902764435ca0dd2f3388eea723a4f92a4eb8302 + - name: "Set environment variables" + shell: bash + env: + TARGET_ENVIRONMENT: ${{ inputs.targetEnvironment }} + TARGET_ACCOUNT_GROUP: ${{ inputs.targetAccountGroup }} + run: | + echo "PR_NUMBER=${TARGET_ENVIRONMENT}" >> $GITHUB_ENV + echo "ENVIRONMENT=${TARGET_ENVIRONMENT}" >> $GITHUB_ENV + echo "GROUP=${TARGET_ACCOUNT_GROUP}" >> $GITHUB_ENV - - name: "Fetch terraform tfvars from nhs-notify-internal" + - name: "Seed test configuration" + shell: bash + env: + PROJECT: nhs + COMPONENT: ${{ inputs.targetComponent }} + run: | + ./scripts/tests/seed-integration-config.sh + + - name: "Checkout nhs-notify-internal for terraform" uses: actions/checkout@v6 with: - sparse-checkout: infrastructure/terraform/etc + sparse-checkout: | + infrastructure/terraform/etc + .github/actions/run-terraform-action path: _internal - - name: "Map terraform config for re-apply" + - name: "Map terraform config" shell: bash run: | cp -r _internal/infrastructure/terraform/etc infrastructure/terraform/ - rm -rf _internal - - name: "Set environment variables" + - name: "Resolve IAM role name" + id: resolve-iam-role + shell: bash + env: + INPUT_IAM_ROLE_NAME: ${{ inputs.iamRoleName }} + run: | + if [ -n "${INPUT_IAM_ROLE_NAME}" ]; then + echo "role_name=${INPUT_IAM_ROLE_NAME}" >> $GITHUB_OUTPUT + else + ROLE_ARN=$(aws sts get-caller-identity --query 'Arn' --output text) + ROLE_NAME=$(echo "${ROLE_ARN}" | sed 's|.*/\(.*\)/.*|\1|') + echo "role_name=${ROLE_NAME}" >> $GITHUB_OUTPUT + fi + + - name: "Generate dynamic environment tfvars" shell: bash env: TARGET_ENVIRONMENT: ${{ inputs.targetEnvironment }} - TARGET_ACCOUNT_GROUP: ${{ inputs.targetAccountGroup }} run: | - echo "PR_NUMBER=${TARGET_ENVIRONMENT}" >> $GITHUB_ENV - echo "ENVIRONMENT=${TARGET_ENVIRONMENT}" >> $GITHUB_ENV - echo "GROUP=${TARGET_ACCOUNT_GROUP}" >> $GITHUB_ENV + TF_ENV_FILE="infrastructure/terraform/etc/env_eu-west-2_${TARGET_ENVIRONMENT}.tfvars" + if [ ! -f "${TF_ENV_FILE}" ] && [ -f "infrastructure/terraform/etc/_dynamic_env.tfvars.tpl" ]; then + cp "infrastructure/terraform/etc/_dynamic_env.tfvars.tpl" "${TF_ENV_FILE}" + echo "environment = \"${TARGET_ENVIRONMENT}\"" >> "${TF_ENV_FILE}" + fi + + - name: "Plan terraform with seeded config" + uses: ./_internal/.github/actions/run-terraform-action + with: + project: nhs + action: plan + component: ${{ inputs.targetComponent }} + environment: ${{ inputs.targetEnvironment }} + group: ${{ inputs.targetAccountGroup }} + iamRoleName: ${{ steps.resolve-iam-role.outputs.role_name }} + + - name: "Apply terraform with seeded config" + uses: ./_internal/.github/actions/run-terraform-action + with: + project: nhs + action: apply + component: ${{ inputs.targetComponent }} + environment: ${{ inputs.targetEnvironment }} + group: ${{ inputs.targetAccountGroup }} + iamRoleName: ${{ steps.resolve-iam-role.outputs.role_name }} - name: Run test - ${{ inputs.testType }} shell: bash diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index 865be531..2537d2a7 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -4,61 +4,4 @@ set -euo pipefail cd "$(git rev-parse --show-toplevel)" -npm ci - -SEED_CONFIG_FILE="$(pwd)/tests/integration/helpers/mock-it-client-subscription.json" -MOCK_IT_CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") -MOCK_APPLICATION_ID="some-application-id" -FUNCTION_NAME="nhs-${ENVIRONMENT}-callbacks-mock-webhook" -MOCK_WEBHOOK_URL=$(aws lambda get-function-url-config \ - --function-name "${FUNCTION_NAME}" \ - --region eu-west-2 \ - --query 'FunctionUrl' --output text) -MOCK_WEBHOOK_API_KEY=$(aws lambda get-function-configuration \ - --function-name "${FUNCTION_NAME}" \ - --region eu-west-2 \ - --query 'Environment.Variables.API_KEY' --output text) -SEED_CONFIG_JSON=$(jq \ - --arg url "${MOCK_WEBHOOK_URL}" \ - --arg key "${MOCK_WEBHOOK_API_KEY}" \ - '.targets[0].invocationEndpoint = $url | .targets[0].apiKey.headerValue = $key' \ - "${SEED_CONFIG_FILE}") - -npm run clients:put -- \ - --client-id "${MOCK_IT_CLIENT_ID}" \ - --environment "${ENVIRONMENT}" \ - --region eu-west-2 \ - --json "${SEED_CONFIG_JSON}" - -npm run applications-map:add -- \ - --client-id "${MOCK_IT_CLIENT_ID}" \ - --application-id "${MOCK_APPLICATION_ID}" \ - --environment "${ENVIRONMENT}" \ - --region eu-west-2 - -export MOCK_WEBHOOK_API_KEY -export MOCK_APPLICATION_ID - -# Re-apply terraform so it picks up the seeded client config and creates API destinations. -# Omit --build-id so tfscaffold does a fresh plan+apply rather than using the stale -# plan file from the initial deploy. -echo "Re-applying terraform to create infrastructure for seeded client configuration..." -cd infrastructure/terraform - -# Generate dynamic environment tfvars if not already present (PR/dynamic envs) -TF_ENV_FILE="etc/env_${AWS_REGION:-eu-west-2}_${ENVIRONMENT}.tfvars" -if [ ! -f "${TF_ENV_FILE}" ] && [ -f "etc/_dynamic_env.tfvars.tpl" ]; then - cp "etc/_dynamic_env.tfvars.tpl" "${TF_ENV_FILE}" - echo "environment = \"${ENVIRONMENT}\"" >> "${TF_ENV_FILE}" -fi - -./bin/terraform.sh \ - --project "${PROJECT}" \ - --region "${AWS_REGION:-eu-west-2}" \ - --component "${COMPONENT}" \ - --environment "${ENVIRONMENT}" \ - --group "${GROUP}" \ - --action apply -cd "$(git rev-parse --show-toplevel)" - npm run test:integration diff --git a/scripts/tests/seed-integration-config.sh b/scripts/tests/seed-integration-config.sh new file mode 100755 index 00000000..882f7909 --- /dev/null +++ b/scripts/tests/seed-integration-config.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +npm ci + +SEED_CONFIG_FILE="$(pwd)/tests/integration/helpers/mock-it-client-subscription.json" +MOCK_IT_CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") +MOCK_APPLICATION_ID="some-application-id" +FUNCTION_NAME="nhs-${ENVIRONMENT}-callbacks-mock-webhook" +MOCK_WEBHOOK_URL=$(aws lambda get-function-url-config \ + --function-name "${FUNCTION_NAME}" \ + --region eu-west-2 \ + --query 'FunctionUrl' --output text) +MOCK_WEBHOOK_API_KEY=$(aws lambda get-function-configuration \ + --function-name "${FUNCTION_NAME}" \ + --region eu-west-2 \ + --query 'Environment.Variables.API_KEY' --output text) +SEED_CONFIG_JSON=$(jq \ + --arg url "${MOCK_WEBHOOK_URL}" \ + --arg key "${MOCK_WEBHOOK_API_KEY}" \ + '.targets[0].invocationEndpoint = $url | .targets[0].apiKey.headerValue = $key' \ + "${SEED_CONFIG_FILE}") + +npm run clients:put -- \ + --client-id "${MOCK_IT_CLIENT_ID}" \ + --environment "${ENVIRONMENT}" \ + --region eu-west-2 \ + --json "${SEED_CONFIG_JSON}" + +npm run applications-map:add -- \ + --client-id "${MOCK_IT_CLIENT_ID}" \ + --application-id "${MOCK_APPLICATION_ID}" \ + --environment "${ENVIRONMENT}" \ + --region eu-west-2 + +echo "MOCK_WEBHOOK_API_KEY=${MOCK_WEBHOOK_API_KEY}" >> "${GITHUB_ENV}" +echo "MOCK_APPLICATION_ID=${MOCK_APPLICATION_ID}" >> "${GITHUB_ENV}" From bb50c8a221fb9f51a00931125fbe91894a75418d Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 12:49:55 +0000 Subject: [PATCH 26/55] fixup! Terraform changes --- .../terraform/components/callbacks/locals.tf | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf index 7315f34f..f0cdb58f 100644 --- a/infrastructure/terraform/components/callbacks/locals.tf +++ b/infrastructure/terraform/components/callbacks/locals.tf @@ -8,7 +8,7 @@ locals { config_files = fileset(local.clients_dir_path, "*.json") - file_clients = length(local.config_files) > 0 ? merge([ + config_clients = length(local.config_files) > 0 ? merge([ for filename in local.config_files : { (replace(filename, ".json", "")) = jsondecode(file("${local.clients_dir_path}/${filename}")) } @@ -53,10 +53,10 @@ locals { } } : {} - all_clients = merge(local.file_clients, local.mock_client) + all_clients = merge(local.config_clients, local.mock_client) - file_targets = length(local.file_clients) > 0 ? merge([ - for client_id, data in local.file_clients : { + config_targets = length(local.config_clients) > 0 ? merge([ + for client_id, data in local.config_clients : { for target in try(data.targets, []) : target.targetId => { client_id = client_id target_id = target.targetId @@ -81,10 +81,10 @@ locals { } } : {} - all_targets = merge(local.file_targets, local.mock_targets) + all_targets = merge(local.config_targets, local.mock_targets) - file_subscriptions = length(local.file_clients) > 0 ? merge([ - for client_id, data in local.file_clients : { + config_subscriptions = length(local.config_clients) > 0 ? merge([ + for client_id, data in local.config_clients : { for subscription in try(data.subscriptions, []) : subscription.subscriptionId => { client_id = client_id subscription_id = subscription.subscriptionId @@ -106,7 +106,7 @@ locals { } } : {} - all_subscriptions = merge(local.file_subscriptions, local.mock_subscriptions) + all_subscriptions = merge(local.config_subscriptions, local.mock_subscriptions) subscription_targets = length(local.all_subscriptions) > 0 ? merge([ for subscription_id, subscription in local.all_subscriptions : { From b8155b422cd21065c7a46318644c7efd212e36e9 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 12:57:54 +0000 Subject: [PATCH 27/55] fixup! Create clients using terraform/tool --- .../terraform/components/callbacks/sync-client-config.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/infrastructure/terraform/components/callbacks/sync-client-config.sh b/infrastructure/terraform/components/callbacks/sync-client-config.sh index 112fade4..fa389dbb 100755 --- a/infrastructure/terraform/components/callbacks/sync-client-config.sh +++ b/infrastructure/terraform/components/callbacks/sync-client-config.sh @@ -30,6 +30,7 @@ aws s3 sync "s3://${bucket_name}/${s3_prefix}" "${clients_dir}/" \ --include "*.json" \ --only-show-errors +# Ensure an empty directory produces a zero-length array rather than a literal "*.json" entry. shopt -s nullglob seeded_files=("${clients_dir}"/*.json) seeded_count="${#seeded_files[@]}" From c940c63e3f2d63d7a9301cf198e3254733f1fd0b Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 12:58:50 +0000 Subject: [PATCH 28/55] Make AWS_ACCOUNT_ID mandatory in sync-client-config script --- .../terraform/components/callbacks/sync-client-config.sh | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/infrastructure/terraform/components/callbacks/sync-client-config.sh b/infrastructure/terraform/components/callbacks/sync-client-config.sh index fa389dbb..e1ebd8ee 100755 --- a/infrastructure/terraform/components/callbacks/sync-client-config.sh +++ b/infrastructure/terraform/components/callbacks/sync-client-config.sh @@ -8,17 +8,13 @@ clients_dir="${repo_root}/infrastructure/terraform/modules/clients" : "${ENVIRONMENT:?ENVIRONMENT must be set}" : "${AWS_REGION:?AWS_REGION must be set}" +: "${AWS_ACCOUNT_ID:?AWS_ACCOUNT_ID must be set}" cd "${repo_root}" rm -f "${clients_dir}"/*.json -account_id="${AWS_ACCOUNT_ID:-}" -if [[ -z "${account_id}" ]]; then - account_id="$(aws sts get-caller-identity --query Account --output text --region "${AWS_REGION}")" -fi - -bucket_name="nhs-${account_id}-${AWS_REGION}-${ENVIRONMENT}-callbacks-subscription-config" +bucket_name="nhs-${AWS_ACCOUNT_ID}-${AWS_REGION}-${ENVIRONMENT}-callbacks-subscription-config" s3_prefix="client_subscriptions/" From 18d302f0379dbcf4199c0dc48224cead0e4ed5c4 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 14:06:33 +0000 Subject: [PATCH 29/55] Revert "fixup! Attempt terraform re-apply" This reverts commit a180f28d635f8967ea4eba4f9725180905984f6f. --- .github/actions/acceptance-tests/action.yaml | 78 +++----------------- scripts/tests/integration.sh | 57 ++++++++++++++ scripts/tests/seed-integration-config.sh | 40 ---------- 3 files changed, 68 insertions(+), 107 deletions(-) delete mode 100755 scripts/tests/seed-integration-config.sh diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml index 469bc716..759c1791 100644 --- a/.github/actions/acceptance-tests/action.yaml +++ b/.github/actions/acceptance-tests/action.yaml @@ -19,11 +19,6 @@ inputs: description: Name of the component under test required: true - iamRoleName: - description: IAM role name for terraform operations - required: false - default: "" - runs: using: "composite" @@ -44,81 +39,30 @@ runs: with: GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} - - name: "Set environment variables" - shell: bash - env: - TARGET_ENVIRONMENT: ${{ inputs.targetEnvironment }} - TARGET_ACCOUNT_GROUP: ${{ inputs.targetAccountGroup }} - run: | - echo "PR_NUMBER=${TARGET_ENVIRONMENT}" >> $GITHUB_ENV - echo "ENVIRONMENT=${TARGET_ENVIRONMENT}" >> $GITHUB_ENV - echo "GROUP=${TARGET_ACCOUNT_GROUP}" >> $GITHUB_ENV + - name: Setup ASDF + uses: asdf-vm/actions/setup@1902764435ca0dd2f3388eea723a4f92a4eb8302 - - name: "Seed test configuration" - shell: bash - env: - PROJECT: nhs - COMPONENT: ${{ inputs.targetComponent }} - run: | - ./scripts/tests/seed-integration-config.sh - - - name: "Checkout nhs-notify-internal for terraform" + - name: "Fetch terraform tfvars from nhs-notify-internal" uses: actions/checkout@v6 with: - sparse-checkout: | - infrastructure/terraform/etc - .github/actions/run-terraform-action + sparse-checkout: infrastructure/terraform/etc path: _internal - - name: "Map terraform config" + - name: "Map terraform config for re-apply" shell: bash run: | cp -r _internal/infrastructure/terraform/etc infrastructure/terraform/ + rm -rf _internal - - name: "Resolve IAM role name" - id: resolve-iam-role - shell: bash - env: - INPUT_IAM_ROLE_NAME: ${{ inputs.iamRoleName }} - run: | - if [ -n "${INPUT_IAM_ROLE_NAME}" ]; then - echo "role_name=${INPUT_IAM_ROLE_NAME}" >> $GITHUB_OUTPUT - else - ROLE_ARN=$(aws sts get-caller-identity --query 'Arn' --output text) - ROLE_NAME=$(echo "${ROLE_ARN}" | sed 's|.*/\(.*\)/.*|\1|') - echo "role_name=${ROLE_NAME}" >> $GITHUB_OUTPUT - fi - - - name: "Generate dynamic environment tfvars" + - name: "Set environment variables" shell: bash env: TARGET_ENVIRONMENT: ${{ inputs.targetEnvironment }} + TARGET_ACCOUNT_GROUP: ${{ inputs.targetAccountGroup }} run: | - TF_ENV_FILE="infrastructure/terraform/etc/env_eu-west-2_${TARGET_ENVIRONMENT}.tfvars" - if [ ! -f "${TF_ENV_FILE}" ] && [ -f "infrastructure/terraform/etc/_dynamic_env.tfvars.tpl" ]; then - cp "infrastructure/terraform/etc/_dynamic_env.tfvars.tpl" "${TF_ENV_FILE}" - echo "environment = \"${TARGET_ENVIRONMENT}\"" >> "${TF_ENV_FILE}" - fi - - - name: "Plan terraform with seeded config" - uses: ./_internal/.github/actions/run-terraform-action - with: - project: nhs - action: plan - component: ${{ inputs.targetComponent }} - environment: ${{ inputs.targetEnvironment }} - group: ${{ inputs.targetAccountGroup }} - iamRoleName: ${{ steps.resolve-iam-role.outputs.role_name }} - - - name: "Apply terraform with seeded config" - uses: ./_internal/.github/actions/run-terraform-action - with: - project: nhs - action: apply - component: ${{ inputs.targetComponent }} - environment: ${{ inputs.targetEnvironment }} - group: ${{ inputs.targetAccountGroup }} - iamRoleName: ${{ steps.resolve-iam-role.outputs.role_name }} + echo "PR_NUMBER=${TARGET_ENVIRONMENT}" >> $GITHUB_ENV + echo "ENVIRONMENT=${TARGET_ENVIRONMENT}" >> $GITHUB_ENV + echo "GROUP=${TARGET_ACCOUNT_GROUP}" >> $GITHUB_ENV - name: Run test - ${{ inputs.testType }} shell: bash diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index 2537d2a7..865be531 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -4,4 +4,61 @@ set -euo pipefail cd "$(git rev-parse --show-toplevel)" +npm ci + +SEED_CONFIG_FILE="$(pwd)/tests/integration/helpers/mock-it-client-subscription.json" +MOCK_IT_CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") +MOCK_APPLICATION_ID="some-application-id" +FUNCTION_NAME="nhs-${ENVIRONMENT}-callbacks-mock-webhook" +MOCK_WEBHOOK_URL=$(aws lambda get-function-url-config \ + --function-name "${FUNCTION_NAME}" \ + --region eu-west-2 \ + --query 'FunctionUrl' --output text) +MOCK_WEBHOOK_API_KEY=$(aws lambda get-function-configuration \ + --function-name "${FUNCTION_NAME}" \ + --region eu-west-2 \ + --query 'Environment.Variables.API_KEY' --output text) +SEED_CONFIG_JSON=$(jq \ + --arg url "${MOCK_WEBHOOK_URL}" \ + --arg key "${MOCK_WEBHOOK_API_KEY}" \ + '.targets[0].invocationEndpoint = $url | .targets[0].apiKey.headerValue = $key' \ + "${SEED_CONFIG_FILE}") + +npm run clients:put -- \ + --client-id "${MOCK_IT_CLIENT_ID}" \ + --environment "${ENVIRONMENT}" \ + --region eu-west-2 \ + --json "${SEED_CONFIG_JSON}" + +npm run applications-map:add -- \ + --client-id "${MOCK_IT_CLIENT_ID}" \ + --application-id "${MOCK_APPLICATION_ID}" \ + --environment "${ENVIRONMENT}" \ + --region eu-west-2 + +export MOCK_WEBHOOK_API_KEY +export MOCK_APPLICATION_ID + +# Re-apply terraform so it picks up the seeded client config and creates API destinations. +# Omit --build-id so tfscaffold does a fresh plan+apply rather than using the stale +# plan file from the initial deploy. +echo "Re-applying terraform to create infrastructure for seeded client configuration..." +cd infrastructure/terraform + +# Generate dynamic environment tfvars if not already present (PR/dynamic envs) +TF_ENV_FILE="etc/env_${AWS_REGION:-eu-west-2}_${ENVIRONMENT}.tfvars" +if [ ! -f "${TF_ENV_FILE}" ] && [ -f "etc/_dynamic_env.tfvars.tpl" ]; then + cp "etc/_dynamic_env.tfvars.tpl" "${TF_ENV_FILE}" + echo "environment = \"${ENVIRONMENT}\"" >> "${TF_ENV_FILE}" +fi + +./bin/terraform.sh \ + --project "${PROJECT}" \ + --region "${AWS_REGION:-eu-west-2}" \ + --component "${COMPONENT}" \ + --environment "${ENVIRONMENT}" \ + --group "${GROUP}" \ + --action apply +cd "$(git rev-parse --show-toplevel)" + npm run test:integration diff --git a/scripts/tests/seed-integration-config.sh b/scripts/tests/seed-integration-config.sh deleted file mode 100755 index 882f7909..00000000 --- a/scripts/tests/seed-integration-config.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -cd "$(git rev-parse --show-toplevel)" - -npm ci - -SEED_CONFIG_FILE="$(pwd)/tests/integration/helpers/mock-it-client-subscription.json" -MOCK_IT_CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") -MOCK_APPLICATION_ID="some-application-id" -FUNCTION_NAME="nhs-${ENVIRONMENT}-callbacks-mock-webhook" -MOCK_WEBHOOK_URL=$(aws lambda get-function-url-config \ - --function-name "${FUNCTION_NAME}" \ - --region eu-west-2 \ - --query 'FunctionUrl' --output text) -MOCK_WEBHOOK_API_KEY=$(aws lambda get-function-configuration \ - --function-name "${FUNCTION_NAME}" \ - --region eu-west-2 \ - --query 'Environment.Variables.API_KEY' --output text) -SEED_CONFIG_JSON=$(jq \ - --arg url "${MOCK_WEBHOOK_URL}" \ - --arg key "${MOCK_WEBHOOK_API_KEY}" \ - '.targets[0].invocationEndpoint = $url | .targets[0].apiKey.headerValue = $key' \ - "${SEED_CONFIG_FILE}") - -npm run clients:put -- \ - --client-id "${MOCK_IT_CLIENT_ID}" \ - --environment "${ENVIRONMENT}" \ - --region eu-west-2 \ - --json "${SEED_CONFIG_JSON}" - -npm run applications-map:add -- \ - --client-id "${MOCK_IT_CLIENT_ID}" \ - --application-id "${MOCK_APPLICATION_ID}" \ - --environment "${ENVIRONMENT}" \ - --region eu-west-2 - -echo "MOCK_WEBHOOK_API_KEY=${MOCK_WEBHOOK_API_KEY}" >> "${GITHUB_ENV}" -echo "MOCK_APPLICATION_ID=${MOCK_APPLICATION_ID}" >> "${GITHUB_ENV}" From 0aa130625a104ba4b9b62fb78ae71d0d71e0fce6 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 14:08:58 +0000 Subject: [PATCH 30/55] Revert github action changes for attempt terraform re-apply --- .github/actions/acceptance-tests/action.yaml | 26 +++----------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml index 759c1791..92fb879c 100644 --- a/.github/actions/acceptance-tests/action.yaml +++ b/.github/actions/acceptance-tests/action.yaml @@ -39,36 +39,16 @@ runs: with: GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} - - name: Setup ASDF - uses: asdf-vm/actions/setup@1902764435ca0dd2f3388eea723a4f92a4eb8302 - - - name: "Fetch terraform tfvars from nhs-notify-internal" - uses: actions/checkout@v6 - with: - sparse-checkout: infrastructure/terraform/etc - path: _internal - - - name: "Map terraform config for re-apply" - shell: bash - run: | - cp -r _internal/infrastructure/terraform/etc infrastructure/terraform/ - rm -rf _internal - - name: "Set environment variables" shell: bash - env: - TARGET_ENVIRONMENT: ${{ inputs.targetEnvironment }} - TARGET_ACCOUNT_GROUP: ${{ inputs.targetAccountGroup }} run: | - echo "PR_NUMBER=${TARGET_ENVIRONMENT}" >> $GITHUB_ENV - echo "ENVIRONMENT=${TARGET_ENVIRONMENT}" >> $GITHUB_ENV - echo "GROUP=${TARGET_ACCOUNT_GROUP}" >> $GITHUB_ENV + echo "PR_NUMBER=${{ inputs.targetEnvironment }}" >> $GITHUB_ENV + echo "ENVIRONMENT=${{ inputs.targetEnvironment }}" >> $GITHUB_ENV - name: Run test - ${{ inputs.testType }} shell: bash env: PROJECT: nhs COMPONENT: ${{ inputs.targetComponent }} - TEST_TYPE: ${{ inputs.testType }} run: | - make "test-${TEST_TYPE}" + make test-${{ inputs.testType }} From 06063da02968ad099af74a94c7d00d6385a9f28b Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 14:11:29 +0000 Subject: [PATCH 31/55] Revert integration.sh terraform changes --- scripts/tests/integration.sh | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index 865be531..454eb5eb 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -39,26 +39,4 @@ npm run applications-map:add -- \ export MOCK_WEBHOOK_API_KEY export MOCK_APPLICATION_ID -# Re-apply terraform so it picks up the seeded client config and creates API destinations. -# Omit --build-id so tfscaffold does a fresh plan+apply rather than using the stale -# plan file from the initial deploy. -echo "Re-applying terraform to create infrastructure for seeded client configuration..." -cd infrastructure/terraform - -# Generate dynamic environment tfvars if not already present (PR/dynamic envs) -TF_ENV_FILE="etc/env_${AWS_REGION:-eu-west-2}_${ENVIRONMENT}.tfvars" -if [ ! -f "${TF_ENV_FILE}" ] && [ -f "etc/_dynamic_env.tfvars.tpl" ]; then - cp "etc/_dynamic_env.tfvars.tpl" "${TF_ENV_FILE}" - echo "environment = \"${ENVIRONMENT}\"" >> "${TF_ENV_FILE}" -fi - -./bin/terraform.sh \ - --project "${PROJECT}" \ - --region "${AWS_REGION:-eu-west-2}" \ - --component "${COMPONENT}" \ - --environment "${ENVIRONMENT}" \ - --group "${GROUP}" \ - --action apply -cd "$(git rev-parse --show-toplevel)" - npm run test:integration From e42b1dbffa8d7749cb142a85d5e2e68db60e046e Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 14:14:26 +0000 Subject: [PATCH 32/55] Move mock client subscription json location --- scripts/tests/integration.sh | 2 +- .../{helpers => fixtures}/mock-it-client-subscription.json | 0 tests/integration/helpers/seed-config.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename tests/integration/{helpers => fixtures}/mock-it-client-subscription.json (100%) diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index 454eb5eb..e044d32a 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -6,7 +6,7 @@ cd "$(git rev-parse --show-toplevel)" npm ci -SEED_CONFIG_FILE="$(pwd)/tests/integration/helpers/mock-it-client-subscription.json" +SEED_CONFIG_FILE="$(pwd)/tests/integration/fixtures/mock-it-client-subscription.json" MOCK_IT_CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") MOCK_APPLICATION_ID="some-application-id" FUNCTION_NAME="nhs-${ENVIRONMENT}-callbacks-mock-webhook" diff --git a/tests/integration/helpers/mock-it-client-subscription.json b/tests/integration/fixtures/mock-it-client-subscription.json similarity index 100% rename from tests/integration/helpers/mock-it-client-subscription.json rename to tests/integration/fixtures/mock-it-client-subscription.json diff --git a/tests/integration/helpers/seed-config.ts b/tests/integration/helpers/seed-config.ts index 39fe2bfc..54d06f42 100644 --- a/tests/integration/helpers/seed-config.ts +++ b/tests/integration/helpers/seed-config.ts @@ -1,4 +1,4 @@ -import seedConfigJson from "./mock-it-client-subscription.json"; +import seedConfigJson from "../fixtures/mock-it-client-subscription.json"; export type MockItClientConfig = typeof seedConfigJson; From e48737f4d93fa47b6c2f835b383a742af3481e8a Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 14:43:59 +0000 Subject: [PATCH 33/55] Use config json for mock terraform clients --- .../terraform/components/callbacks/locals.tf | 83 ++++--------------- .../callbacks/sync-client-config.sh | 8 ++ .../fixtures/mock-it-client-subscription.json | 4 +- 3 files changed, 28 insertions(+), 67 deletions(-) diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf index f0cdb58f..71015e5f 100644 --- a/infrastructure/terraform/components/callbacks/locals.tf +++ b/infrastructure/terraform/components/callbacks/locals.tf @@ -5,55 +5,33 @@ locals { root_domain_id = local.acct.route53_zone_ids["client-callbacks"] clients_dir_path = "${path.module}/../../modules/clients" + mock_client_file = "mock-it-client-subscription.json" + mock_client_key = replace(local.mock_client_file, ".json", "") config_files = fileset(local.clients_dir_path, "*.json") - config_clients = length(local.config_files) > 0 ? merge([ + raw_config_clients = length(local.config_files) > 0 ? merge([ for filename in local.config_files : { (replace(filename, ".json", "")) = jsondecode(file("${local.clients_dir_path}/${filename}")) } ]...) : {} - # Automatic test client when mock webhook is deployed - mock_client = var.deploy_mock_webhook ? { - "mock-client" = { - clientId = "mock-client" + # Replace placeholder values in the mock fixture with the live Lambda URL and + # generated API key. Terraform resolves these from resource references, so + # this works on first deploy (values are "known after apply" during plan). + config_clients = var.deploy_mock_webhook ? merge(local.raw_config_clients, { + (local.mock_client_key) = merge(try(local.raw_config_clients[local.mock_client_key], {}), { targets = [ - { - targetId = "mock-target-1" - type = "API" - - invocationEndpoint = aws_lambda_function_url.mock_webhook[0].function_url - invocationMethod = "POST" - invocationRateLimit = 10 - - apiKey = { - headerName = "x-api-key" - headerValue = random_password.mock_webhook_api_key[0].result - } - } + for t in try(local.raw_config_clients[local.mock_client_key].targets, []) : + merge(t, { + invocationEndpoint = aws_lambda_function_url.mock_webhook[0].function_url + apiKey = merge(t.apiKey, { headerValue = random_password.mock_webhook_api_key[0].result }) + }) ] + }) + }) : local.raw_config_clients - subscriptions = [ - { - subscriptionId = "mock-subscription-message-status" - subscriptionType = "MessageStatus" - targetIds = ["mock-target-1"] - messageStatuses = ["DELIVERED", "FAILED"] - }, - { - subscriptionId = "mock-subscription-channel-status" - subscriptionType = "ChannelStatus" - targetIds = ["mock-target-1"] - channelType = "NHSAPP" - channelStatuses = ["DELIVERED", "FAILED"] - supplierStatuses = ["delivered", "permanent_failure"] - } - ] - } - } : {} - - all_clients = merge(local.config_clients, local.mock_client) + all_clients = local.config_clients config_targets = length(local.config_clients) > 0 ? merge([ for client_id, data in local.config_clients : { @@ -69,19 +47,7 @@ locals { } ]...) : {} - mock_targets = var.deploy_mock_webhook ? { - "mock-target-1" = { - client_id = "mock-client" - target_id = "mock-target-1" - invocation_endpoint = aws_lambda_function_url.mock_webhook[0].function_url - invocation_rate_limit_per_second = 10 - http_method = "POST" - header_name = "x-api-key" - header_value = random_password.mock_webhook_api_key[0].result - } - } : {} - - all_targets = merge(local.config_targets, local.mock_targets) + all_targets = local.config_targets config_subscriptions = length(local.config_clients) > 0 ? merge([ for client_id, data in local.config_clients : { @@ -93,20 +59,7 @@ locals { } ]...) : {} - mock_subscriptions = var.deploy_mock_webhook ? { - "mock-subscription-message-status" = { - client_id = "mock-client" - subscription_id = "mock-subscription-message-status" - target_ids = ["mock-target-1"] - } - "mock-subscription-channel-status" = { - client_id = "mock-client" - subscription_id = "mock-subscription-channel-status" - target_ids = ["mock-target-1"] - } - } : {} - - all_subscriptions = merge(local.config_subscriptions, local.mock_subscriptions) + all_subscriptions = local.config_subscriptions subscription_targets = length(local.all_subscriptions) > 0 ? merge([ for subscription_id, subscription in local.all_subscriptions : { diff --git a/infrastructure/terraform/components/callbacks/sync-client-config.sh b/infrastructure/terraform/components/callbacks/sync-client-config.sh index e1ebd8ee..8f109687 100755 --- a/infrastructure/terraform/components/callbacks/sync-client-config.sh +++ b/infrastructure/terraform/components/callbacks/sync-client-config.sh @@ -26,6 +26,14 @@ aws s3 sync "s3://${bucket_name}/${s3_prefix}" "${clients_dir}/" \ --include "*.json" \ --only-show-errors +# When deploying the mock webhook, copy the integration test client fixture into +# the clients directory. Terraform substitutes the live Lambda URL and API key +# at plan/apply time via resource references in locals.tf. +if [[ "${DEPLOY_MOCK_WEBHOOK:-false}" == "true" ]]; then + echo "Copying mock integration test client subscription config" + cp "${repo_root}/tests/integration/fixtures/mock-it-client-subscription.json" "${clients_dir}/" +fi + # Ensure an empty directory produces a zero-length array rather than a literal "*.json" entry. shopt -s nullglob seeded_files=("${clients_dir}"/*.json) diff --git a/tests/integration/fixtures/mock-it-client-subscription.json b/tests/integration/fixtures/mock-it-client-subscription.json index 592c97b6..2845e05e 100644 --- a/tests/integration/fixtures/mock-it-client-subscription.json +++ b/tests/integration/fixtures/mock-it-client-subscription.json @@ -33,9 +33,9 @@ { "apiKey": { "headerName": "x-api-key", - "headerValue": "REPLACED_BY_SCRIPT" + "headerValue": "REPLACED_BY_TERRAFORM" }, - "invocationEndpoint": "https://REPLACED_BY_SCRIPT", + "invocationEndpoint": "https://REPLACED_BY_TERRAFORM", "invocationMethod": "POST", "invocationRateLimit": 10, "targetId": "target-23b2ee2f-8e81-43cd-9bb8-5ea30a09f779", From 8ad15bfd470b087c00610fd9faad3d311a222c20 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 14:49:17 +0000 Subject: [PATCH 34/55] Upload the config back to S3 --- .../components/callbacks/s3_bucket_client_config.tf | 13 +++++++++++++ .../components/callbacks/sync-client-config.sh | 11 ++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf index 58b016e4..9b9962f6 100644 --- a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf +++ b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf @@ -1,3 +1,16 @@ +resource "aws_s3_object" "mock_client_config" { + count = var.deploy_mock_webhook ? 1 : 0 + + bucket = module.client_config_bucket.id + key = "client_subscriptions/mock-it-client-subscription.json" + content = jsonencode(local.config_clients[local.mock_client_key]) + + kms_key_id = module.kms.key_arn + server_side_encryption = "aws:kms" + + content_type = "application/json" +} + module "client_config_bucket" { source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-s3bucket.zip" diff --git a/infrastructure/terraform/components/callbacks/sync-client-config.sh b/infrastructure/terraform/components/callbacks/sync-client-config.sh index 8f109687..e7a48e25 100755 --- a/infrastructure/terraform/components/callbacks/sync-client-config.sh +++ b/infrastructure/terraform/components/callbacks/sync-client-config.sh @@ -26,11 +26,12 @@ aws s3 sync "s3://${bucket_name}/${s3_prefix}" "${clients_dir}/" \ --include "*.json" \ --only-show-errors -# When deploying the mock webhook, copy the integration test client fixture into -# the clients directory. Terraform substitutes the live Lambda URL and API key -# at plan/apply time via resource references in locals.tf. -if [[ "${DEPLOY_MOCK_WEBHOOK:-false}" == "true" ]]; then - echo "Copying mock integration test client subscription config" +# When deploying the mock webhook, seed the clients directory with the fixture if +# the file was not already pulled from S3 (i.e. first deploy only). Terraform +# substitutes the live Lambda URL and API key at apply time and persists the +# resolved config back to S3 via aws_s3_object so subsequent syncs pick it up. +if [[ "${DEPLOY_MOCK_WEBHOOK:-false}" == "true" ]] && [[ ! -f "${clients_dir}/mock-it-client-subscription.json" ]]; then + echo "Seeding mock integration test client subscription config from fixture" cp "${repo_root}/tests/integration/fixtures/mock-it-client-subscription.json" "${clients_dir}/" fi From 12903fb7fe5b0203eca1b1677c6c07efe3ef32af Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 14:55:39 +0000 Subject: [PATCH 35/55] Load the application-id map with the correct mock client --- infrastructure/terraform/components/callbacks/locals.tf | 1 + .../components/callbacks/ssm_parameter_applications_map.tf | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf index 71015e5f..32f17177 100644 --- a/infrastructure/terraform/components/callbacks/locals.tf +++ b/infrastructure/terraform/components/callbacks/locals.tf @@ -7,6 +7,7 @@ locals { clients_dir_path = "${path.module}/../../modules/clients" mock_client_file = "mock-it-client-subscription.json" mock_client_key = replace(local.mock_client_file, ".json", "") + mock_client_id = var.deploy_mock_webhook ? local.raw_config_clients[local.mock_client_key].clientId : "" config_files = fileset(local.clients_dir_path, "*.json") diff --git a/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf b/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf index 1e9b6925..93f38c45 100644 --- a/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf +++ b/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf @@ -4,7 +4,7 @@ resource "aws_ssm_parameter" "applications_map" { key_id = module.kms.key_arn value = var.deploy_mock_webhook ? jsonencode({ - "mock-client" = "mock-application-id" + (local.mock_client_id) = "mock-application-id" }) : jsonencode({}) lifecycle { From aeb82e91fa483fa2e4ab0c82bdfd5ce53a5456e2 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 15:03:38 +0000 Subject: [PATCH 36/55] Pull the integration test vars down from client config --- scripts/tests/integration.sh | 39 ++++++++++++++---------------------- 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index e044d32a..ac5d509b 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -4,37 +4,28 @@ set -euo pipefail cd "$(git rev-parse --show-toplevel)" +: "${ENVIRONMENT:?ENVIRONMENT must be set}" +: "${PROJECT:?PROJECT must be set}" + npm ci SEED_CONFIG_FILE="$(pwd)/tests/integration/fixtures/mock-it-client-subscription.json" MOCK_IT_CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") -MOCK_APPLICATION_ID="some-application-id" -FUNCTION_NAME="nhs-${ENVIRONMENT}-callbacks-mock-webhook" -MOCK_WEBHOOK_URL=$(aws lambda get-function-url-config \ - --function-name "${FUNCTION_NAME}" \ - --region eu-west-2 \ - --query 'FunctionUrl' --output text) -MOCK_WEBHOOK_API_KEY=$(aws lambda get-function-configuration \ - --function-name "${FUNCTION_NAME}" \ - --region eu-west-2 \ - --query 'Environment.Variables.API_KEY' --output text) -SEED_CONFIG_JSON=$(jq \ - --arg url "${MOCK_WEBHOOK_URL}" \ - --arg key "${MOCK_WEBHOOK_API_KEY}" \ - '.targets[0].invocationEndpoint = $url | .targets[0].apiKey.headerValue = $key' \ - "${SEED_CONFIG_FILE}") - -npm run clients:put -- \ - --client-id "${MOCK_IT_CLIENT_ID}" \ - --environment "${ENVIRONMENT}" \ - --region eu-west-2 \ - --json "${SEED_CONFIG_JSON}" -npm run applications-map:add -- \ +CLIENT_CONFIG=$(npm run clients:get -- \ --client-id "${MOCK_IT_CLIENT_ID}" \ - --application-id "${MOCK_APPLICATION_ID}" \ --environment "${ENVIRONMENT}" \ - --region eu-west-2 + --region eu-west-2) + +MOCK_WEBHOOK_API_KEY=$(echo "${CLIENT_CONFIG}" | jq -r '.targets[0].apiKey.headerValue') + +SSM_PARAMETER_NAME="/${PROJECT}/${ENVIRONMENT}/callbacks/applications-map" +APPLICATIONS_MAP=$(aws ssm get-parameter \ + --name "${SSM_PARAMETER_NAME}" \ + --with-decryption \ + --region eu-west-2 \ + --query 'Parameter.Value' --output text) +MOCK_APPLICATION_ID=$(echo "${APPLICATIONS_MAP}" | jq -r --arg id "${MOCK_IT_CLIENT_ID}" '.[$id]') export MOCK_WEBHOOK_API_KEY export MOCK_APPLICATION_ID From 36fd5a6efdebdde3f7826166a4b906c568e13364 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 15:20:42 +0000 Subject: [PATCH 37/55] fixup! Move mock client subscription json location --- .../terraform/components/callbacks/locals.tf | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf index 32f17177..ec9871da 100644 --- a/infrastructure/terraform/components/callbacks/locals.tf +++ b/infrastructure/terraform/components/callbacks/locals.tf @@ -20,17 +20,20 @@ locals { # Replace placeholder values in the mock fixture with the live Lambda URL and # generated API key. Terraform resolves these from resource references, so # this works on first deploy (values are "known after apply" during plan). - config_clients = var.deploy_mock_webhook ? merge(local.raw_config_clients, { - (local.mock_client_key) = merge(try(local.raw_config_clients[local.mock_client_key], {}), { - targets = [ - for t in try(local.raw_config_clients[local.mock_client_key].targets, []) : - merge(t, { - invocationEndpoint = aws_lambda_function_url.mock_webhook[0].function_url - apiKey = merge(t.apiKey, { headerValue = random_password.mock_webhook_api_key[0].result }) - }) - ] - }) - }) : local.raw_config_clients + config_clients = tomap(merge( + local.raw_config_clients, + var.deploy_mock_webhook ? tomap({ + (local.mock_client_key) = merge(try(local.raw_config_clients[local.mock_client_key], {}), { + targets = [ + for t in try(local.raw_config_clients[local.mock_client_key].targets, []) : + merge(t, { + invocationEndpoint = aws_lambda_function_url.mock_webhook[0].function_url + apiKey = merge(t.apiKey, { headerValue = random_password.mock_webhook_api_key[0].result }) + }) + ] + }) + }) : tomap({}) + )) all_clients = local.config_clients From c8dd3030d7773dca57f2ae98b85ba9a8354af2eb Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 15:25:36 +0000 Subject: [PATCH 38/55] fixup! Move mock client subscription json location --- infrastructure/terraform/components/callbacks/locals.tf | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf index ec9871da..4d9cfece 100644 --- a/infrastructure/terraform/components/callbacks/locals.tf +++ b/infrastructure/terraform/components/callbacks/locals.tf @@ -7,7 +7,7 @@ locals { clients_dir_path = "${path.module}/../../modules/clients" mock_client_file = "mock-it-client-subscription.json" mock_client_key = replace(local.mock_client_file, ".json", "") - mock_client_id = var.deploy_mock_webhook ? local.raw_config_clients[local.mock_client_key].clientId : "" + mock_client_path = "${path.module}/../../../../tests/integration/fixtures/${local.mock_client_file}" config_files = fileset(local.clients_dir_path, "*.json") @@ -17,15 +17,18 @@ locals { } ]...) : {} + mock_client = try(local.raw_config_clients[local.mock_client_key], jsondecode(file(local.mock_client_path))) + mock_client_id = var.deploy_mock_webhook ? local.mock_client.clientId : "" + # Replace placeholder values in the mock fixture with the live Lambda URL and # generated API key. Terraform resolves these from resource references, so # this works on first deploy (values are "known after apply" during plan). config_clients = tomap(merge( local.raw_config_clients, var.deploy_mock_webhook ? tomap({ - (local.mock_client_key) = merge(try(local.raw_config_clients[local.mock_client_key], {}), { + (local.mock_client_key) = merge(local.mock_client, { targets = [ - for t in try(local.raw_config_clients[local.mock_client_key].targets, []) : + for t in try(local.mock_client.targets, []) : merge(t, { invocationEndpoint = aws_lambda_function_url.mock_webhook[0].function_url apiKey = merge(t.apiKey, { headerValue = random_password.mock_webhook_api_key[0].result }) From 408507b510ea2a775716d3698af507940d8d24f6 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 15:41:44 +0000 Subject: [PATCH 39/55] SSM put tool --- package.json | 1 + .../package.json | 1 + .../cli/applications-map-get.test.ts | 84 +++++++++++++++++++ .../repository/ssm-applications-map.test.ts | 59 +++++++++++++ .../entrypoint/cli/applications-map-get.ts | 46 ++++++++++ .../src/entrypoint/cli/index.ts | 2 + .../src/repository/ssm-applications-map.ts | 23 +++++ 7 files changed, 216 insertions(+) create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-get.test.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/applications-map-get.ts diff --git a/package.json b/package.json index 2954ba4f..d5b58eef 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "typecheck": "npm run typecheck --workspaces", "verify": "npm run lint && npm run typecheck && npm run test:unit", "applications-map:add": "npm run --silent applications-map-add --workspace tools/client-subscriptions-management --", + "applications-map:get": "npm run --silent applications-map-get --workspace tools/client-subscriptions-management --", "clients:list": "npm run --silent clients-list --workspace tools/client-subscriptions-management --", "clients:get": "npm run --silent clients-get --workspace tools/client-subscriptions-management --", "clients:put": "npm run --silent clients-put --workspace tools/client-subscriptions-management --", diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json index 55782eff..90dfcc87 100644 --- a/tools/client-subscriptions-management/package.json +++ b/tools/client-subscriptions-management/package.json @@ -14,6 +14,7 @@ "targets-add": "tsx ./src/entrypoint/cli/index.ts targets-add", "targets-del": "tsx ./src/entrypoint/cli/index.ts targets-del", "applications-map-add": "tsx ./src/entrypoint/cli/index.ts applications-map-add", + "applications-map-get": "tsx ./src/entrypoint/cli/index.ts applications-map-get", "lint": "eslint .", "lint:fix": "eslint . --fix", "test:unit": "jest", diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-get.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-get.test.ts new file mode 100644 index 00000000..8d44efeb --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/applications-map-get.test.ts @@ -0,0 +1,84 @@ +import * as cli from "src/entrypoint/cli/applications-map-get"; +import * as helper from "src/entrypoint/cli/helper"; +import { + captureCliConsoleState, + expectWrappedCliError, + resetCliConsoleState, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; + +const mockGetApplication = jest.fn(); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createSsmApplicationsMapRepository: jest.fn(), +})); + +const mockCreateSsmApplicationsMapRepository = + helper.createSsmApplicationsMapRepository as jest.Mock; + +describe("applications-map-get CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + const baseArgs = [ + "node", + "script", + "--client-id", + "client-1", + "--parameter-name", + "/nhs/dev/callbacks/applications-map", + ]; + + beforeEach(() => { + mockGetApplication.mockReset(); + mockCreateSsmApplicationsMapRepository.mockReset(); + mockCreateSsmApplicationsMapRepository.mockReturnValue({ + getApplication: mockGetApplication, + }); + resetCliConsoleState(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("prints the application ID when mapping exists", async () => { + mockGetApplication.mockResolvedValue("app-1"); + + await cli.main(baseArgs); + + expect(mockCreateSsmApplicationsMapRepository).toHaveBeenCalledWith( + expect.objectContaining({ + "client-id": "client-1", + "parameter-name": "/nhs/dev/callbacks/applications-map", + }), + ); + expect(mockGetApplication).toHaveBeenCalledWith("client-1"); + expect(console.log).toHaveBeenCalledWith("app-1"); + }); + + it("does not log the application-id in other messages", async () => { + mockGetApplication.mockResolvedValue("app-1"); + + await cli.main(baseArgs); + + const logMessages = (console.log as jest.Mock).mock.calls.flat(); + expect(logMessages).toEqual(["app-1"]); + }); + + it("throws when no mapping exists for the client", async () => { + mockGetApplication.mockResolvedValue(undefined); + + await expectWrappedCliError( + cli.main, + baseArgs, + "No application mapping exists for client: client-1", + ); + }); + + it("handles repository errors", async () => { + mockGetApplication.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, baseArgs); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/repository/ssm-applications-map.test.ts b/tools/client-subscriptions-management/src/__tests__/repository/ssm-applications-map.test.ts index b169c5d5..afb94e41 100644 --- a/tools/client-subscriptions-management/src/__tests__/repository/ssm-applications-map.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/repository/ssm-applications-map.test.ts @@ -14,6 +14,65 @@ const createRepository = (send: jest.Mock = jest.fn()) => { }; describe("SsmApplicationsMapRepository", () => { + describe("getApplication", () => { + it("returns the application ID for an existing client", async () => { + const { repository, send } = createRepository(); + send.mockResolvedValueOnce({ + Parameter: { + Value: JSON.stringify({ "client-1": "app-1", "client-2": "app-2" }), + }, + }); + + const result = await repository.getApplication("client-1"); + + expect(send).toHaveBeenCalledWith(expect.any(GetParameterCommand)); + expect(result).toBe("app-1"); + }); + + it("returns undefined when the client is not in the map", async () => { + const { repository, send } = createRepository(); + send.mockResolvedValueOnce({ + Parameter: { Value: JSON.stringify({ "other-client": "app-1" }) }, + }); + + const result = await repository.getApplication("client-1"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when parameter does not exist", async () => { + const { repository, send } = createRepository(); + const error = Object.assign(new Error("not found"), { + name: "ParameterNotFound", + }); + send.mockRejectedValueOnce(error); + + const result = await repository.getApplication("client-1"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when parameter has no value", async () => { + const { repository, send } = createRepository(); + send.mockResolvedValueOnce({ Parameter: {} }); + + const result = await repository.getApplication("client-1"); + + expect(result).toBeUndefined(); + }); + + it("rethrows unexpected SSM errors", async () => { + const { repository, send } = createRepository(); + send.mockRejectedValueOnce( + Object.assign(new Error("Network failure"), { name: "NetworkError" }), + ); + + await expect(repository.getApplication("client-1")).rejects.toThrow( + "Network failure", + ); + }); + }); + describe("addApplication", () => { it("reads existing map, merges new entry, and writes back", async () => { const { repository, send } = createRepository(); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-get.ts b/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-get.ts new file mode 100644 index 00000000..5ffe2192 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/applications-map-get.ts @@ -0,0 +1,46 @@ +import type { Argv } from "yargs"; +import { + type CliCommand, + type ClientCliArgs, + type SsmCliArgs, + clientIdOption, + commonOptions, + createSsmApplicationsMapRepository, + parameterNameOption, + runCommand, +} from "src/entrypoint/cli/helper"; + +type ApplicationsMapGetArgs = ClientCliArgs & SsmCliArgs; + +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + ...parameterNameOption, + }); + +export const handler: CliCommand["handler"] = async ( + argv, +) => { + const repository = createSsmApplicationsMapRepository(argv); + const applicationId = await repository.getApplication(argv["client-id"]); + + if (applicationId) { + console.log(applicationId); + } else { + throw new Error( + `No application mapping exists for client: ${argv["client-id"]}`, + ); + } +}; + +export const command: CliCommand = { + command: "applications-map-get", + describe: "Get the application ID mapped to a client", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/index.ts b/tools/client-subscriptions-management/src/entrypoint/cli/index.ts index 450e94d1..d13a11a8 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/index.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/index.ts @@ -1,4 +1,5 @@ import { command as applicationsMapAddCommand } from "src/entrypoint/cli/applications-map-add"; +import { command as applicationsMapGetCommand } from "src/entrypoint/cli/applications-map-get"; import { command as clientsGetCommand } from "src/entrypoint/cli/clients-get"; import { command as clientsListCommand } from "src/entrypoint/cli/clients-list"; import { command as clientsPutCommand } from "src/entrypoint/cli/clients-put"; @@ -14,6 +15,7 @@ import { command as targetsListCommand } from "src/entrypoint/cli/targets-list"; export const commands: AnyCliCommand[] = [ applicationsMapAddCommand, + applicationsMapGetCommand, clientsListCommand, clientsGetCommand, clientsPutCommand, diff --git a/tools/client-subscriptions-management/src/repository/ssm-applications-map.ts b/tools/client-subscriptions-management/src/repository/ssm-applications-map.ts index 04b41b9f..13553d85 100644 --- a/tools/client-subscriptions-management/src/repository/ssm-applications-map.ts +++ b/tools/client-subscriptions-management/src/repository/ssm-applications-map.ts @@ -10,6 +10,29 @@ export default class SsmApplicationsMapRepository { private readonly parameterName: string, ) {} + async getApplication(clientId: string): Promise { + try { + const response = await this.client.send( + new GetParameterCommand({ + Name: this.parameterName, + WithDecryption: true, + }), + ); + if (response.Parameter?.Value) { + const map = JSON.parse(response.Parameter.Value) as Record< + string, + string + >; + return map[clientId]; + } + } catch (error) { + if (error instanceof Error && error.name !== "ParameterNotFound") { + throw error; + } + } + return undefined; + } + async addApplication( clientId: string, applicationId: string, From 986b4ab980c7b4fb2c1c7dde4839802df4fd434d Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 15:42:11 +0000 Subject: [PATCH 40/55] Fix integration script jq commands --- scripts/tests/integration.sh | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index ac5d509b..defef9f9 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -5,27 +5,23 @@ set -euo pipefail cd "$(git rev-parse --show-toplevel)" : "${ENVIRONMENT:?ENVIRONMENT must be set}" -: "${PROJECT:?PROJECT must be set}" npm ci SEED_CONFIG_FILE="$(pwd)/tests/integration/fixtures/mock-it-client-subscription.json" MOCK_IT_CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") -CLIENT_CONFIG=$(npm run clients:get -- \ +CLIENT_CONFIG=$(npm run --silent clients:get -- \ --client-id "${MOCK_IT_CLIENT_ID}" \ --environment "${ENVIRONMENT}" \ --region eu-west-2) MOCK_WEBHOOK_API_KEY=$(echo "${CLIENT_CONFIG}" | jq -r '.targets[0].apiKey.headerValue') -SSM_PARAMETER_NAME="/${PROJECT}/${ENVIRONMENT}/callbacks/applications-map" -APPLICATIONS_MAP=$(aws ssm get-parameter \ - --name "${SSM_PARAMETER_NAME}" \ - --with-decryption \ - --region eu-west-2 \ - --query 'Parameter.Value' --output text) -MOCK_APPLICATION_ID=$(echo "${APPLICATIONS_MAP}" | jq -r --arg id "${MOCK_IT_CLIENT_ID}" '.[$id]') +MOCK_APPLICATION_ID=$(npm run --silent applications-map:get -- \ + --client-id "${MOCK_IT_CLIENT_ID}" \ + --environment "${ENVIRONMENT}" \ + --region eu-west-2) export MOCK_WEBHOOK_API_KEY export MOCK_APPLICATION_ID From 0744e32d23f52fb4b23400f5490df4507c102af0 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 15:46:24 +0000 Subject: [PATCH 41/55] fixup! Move mock client subscription json location --- .../terraform/components/callbacks/locals.tf | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf index 4d9cfece..1fee64f8 100644 --- a/infrastructure/terraform/components/callbacks/locals.tf +++ b/infrastructure/terraform/components/callbacks/locals.tf @@ -19,45 +19,49 @@ locals { mock_client = try(local.raw_config_clients[local.mock_client_key], jsondecode(file(local.mock_client_path))) mock_client_id = var.deploy_mock_webhook ? local.mock_client.clientId : "" - - # Replace placeholder values in the mock fixture with the live Lambda URL and - # generated API key. Terraform resolves these from resource references, so - # this works on first deploy (values are "known after apply" during plan). - config_clients = tomap(merge( + base_config_clients = tomap(merge( local.raw_config_clients, var.deploy_mock_webhook ? tomap({ - (local.mock_client_key) = merge(local.mock_client, { - targets = [ - for t in try(local.mock_client.targets, []) : - merge(t, { - invocationEndpoint = aws_lambda_function_url.mock_webhook[0].function_url - apiKey = merge(t.apiKey, { headerValue = random_password.mock_webhook_api_key[0].result }) - }) - ] - }) + (local.mock_client_key) = local.mock_client }) : tomap({}) )) + # Replace placeholder values in the mock fixture with the live Lambda URL and + # generated API key. Terraform resolves these from resource references, so + # this works on first deploy (values are "known after apply" during plan). + config_clients = tomap({ + for client_key, client_data in local.base_config_clients : + client_key => merge(client_data, { + targets = [ + for target in try(client_data.targets, []) : + client_key == local.mock_client_key ? merge(target, { + invocationEndpoint = aws_lambda_function_url.mock_webhook[0].function_url + apiKey = merge(target.apiKey, { headerValue = random_password.mock_webhook_api_key[0].result }) + }) : target + ] + }) + }) + all_clients = local.config_clients - config_targets = length(local.config_clients) > 0 ? merge([ - for client_id, data in local.config_clients : { + config_targets = length(local.base_config_clients) > 0 ? merge([ + for client_id, data in local.base_config_clients : { for target in try(data.targets, []) : target.targetId => { client_id = client_id target_id = target.targetId - invocation_endpoint = target.invocationEndpoint + invocation_endpoint = client_id == local.mock_client_key ? aws_lambda_function_url.mock_webhook[0].function_url : target.invocationEndpoint invocation_rate_limit_per_second = target.invocationRateLimit http_method = target.invocationMethod header_name = target.apiKey.headerName - header_value = target.apiKey.headerValue + header_value = client_id == local.mock_client_key ? random_password.mock_webhook_api_key[0].result : target.apiKey.headerValue } } ]...) : {} all_targets = local.config_targets - config_subscriptions = length(local.config_clients) > 0 ? merge([ - for client_id, data in local.config_clients : { + config_subscriptions = length(local.base_config_clients) > 0 ? merge([ + for client_id, data in local.base_config_clients : { for subscription in try(data.subscriptions, []) : subscription.subscriptionId => { client_id = client_id subscription_id = subscription.subscriptionId From 9ce6a37518e6a79fe79e45b174098928baf4e892 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 17:09:24 +0000 Subject: [PATCH 42/55] Tidy up terraform --- .../cloudwatch_metric_alarm_dlq_depth.tf | 2 +- .../terraform/components/callbacks/locals.tf | 85 ++++++++++--------- .../callbacks/module_client_destination.tf | 4 +- .../callbacks/s3_bucket_client_config.tf | 2 +- .../callbacks/sync-client-config.sh | 19 ++--- 5 files changed, 59 insertions(+), 53 deletions(-) diff --git a/infrastructure/terraform/components/callbacks/cloudwatch_metric_alarm_dlq_depth.tf b/infrastructure/terraform/components/callbacks/cloudwatch_metric_alarm_dlq_depth.tf index c38fb58f..77e942ec 100644 --- a/infrastructure/terraform/components/callbacks/cloudwatch_metric_alarm_dlq_depth.tf +++ b/infrastructure/terraform/components/callbacks/cloudwatch_metric_alarm_dlq_depth.tf @@ -1,5 +1,5 @@ resource "aws_cloudwatch_metric_alarm" "client_dlq_depth" { - for_each = toset(keys(local.all_clients)) + for_each = toset(keys(local.config_clients)) alarm_name = "${local.csi}-${each.key}-dlq-depth" alarm_description = join(" ", [ diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf index 1fee64f8..8f6a1110 100644 --- a/infrastructure/terraform/components/callbacks/locals.tf +++ b/infrastructure/terraform/components/callbacks/locals.tf @@ -9,78 +9,85 @@ locals { mock_client_key = replace(local.mock_client_file, ".json", "") mock_client_path = "${path.module}/../../../../tests/integration/fixtures/${local.mock_client_file}" - config_files = fileset(local.clients_dir_path, "*.json") + discovered_config_files = fileset(local.clients_dir_path, "*.json") + config_files = var.deploy_mock_webhook ? setunion( + local.discovered_config_files, + toset([local.mock_client_file]) + ) : local.discovered_config_files - raw_config_clients = length(local.config_files) > 0 ? merge([ + config_clients = merge([ for filename in local.config_files : { - (replace(filename, ".json", "")) = jsondecode(file("${local.clients_dir_path}/${filename}")) + # The mock file may be absent on first apply (bucket/key not created yet), + # but present on subsequent applies after sync from S3 + (replace(filename, ".json", "")) = jsondecode(file( + filename == local.mock_client_file && !fileexists("${local.clients_dir_path}/${filename}") + ? local.mock_client_path + : "${local.clients_dir_path}/${filename}" + )) } - ]...) : {} + ]...) - mock_client = try(local.raw_config_clients[local.mock_client_key], jsondecode(file(local.mock_client_path))) - mock_client_id = var.deploy_mock_webhook ? local.mock_client.clientId : "" - base_config_clients = tomap(merge( - local.raw_config_clients, - var.deploy_mock_webhook ? tomap({ - (local.mock_client_key) = local.mock_client - }) : tomap({}) - )) + mock_client_id = var.deploy_mock_webhook ? local.config_clients[local.mock_client_key].clientId : "" - # Replace placeholder values in the mock fixture with the live Lambda URL and - # generated API key. Terraform resolves these from resource references, so - # this works on first deploy (values are "known after apply" during plan). - config_clients = tomap({ - for client_key, client_data in local.base_config_clients : - client_key => merge(client_data, { + # Enriched mock client config with live Lambda URL and API key (for S3 upload) + mock_client_config = var.deploy_mock_webhook ? merge( + local.config_clients[local.mock_client_key], + { targets = [ - for target in try(client_data.targets, []) : - client_key == local.mock_client_key ? merge(target, { + for target in try(local.config_clients[local.mock_client_key].targets, []) : + merge(target, { invocationEndpoint = aws_lambda_function_url.mock_webhook[0].function_url apiKey = merge(target.apiKey, { headerValue = random_password.mock_webhook_api_key[0].result }) - }) : target + }) ] - }) - }) - - all_clients = local.config_clients + } + ) : null - config_targets = length(local.base_config_clients) > 0 ? merge([ - for client_id, data in local.base_config_clients : { + raw_targets = merge([ + for client_id, data in local.config_clients : { for target in try(data.targets, []) : target.targetId => { client_id = client_id target_id = target.targetId - invocation_endpoint = client_id == local.mock_client_key ? aws_lambda_function_url.mock_webhook[0].function_url : target.invocationEndpoint + invocation_endpoint = target.invocationEndpoint invocation_rate_limit_per_second = target.invocationRateLimit http_method = target.invocationMethod header_name = target.apiKey.headerName - header_value = client_id == local.mock_client_key ? random_password.mock_webhook_api_key[0].result : target.apiKey.headerValue + header_value = target.apiKey.headerValue } } - ]...) : {} + ]...) - all_targets = local.config_targets + # Override mock targets with live Lambda URL when deployed + config_targets = var.deploy_mock_webhook ? merge( + local.raw_targets, + { for target_id, target in local.raw_targets : + target_id => merge(target, { + invocation_endpoint = aws_lambda_function_url.mock_webhook[0].function_url + header_value = random_password.mock_webhook_api_key[0].result + }) + if target.client_id == local.mock_client_key + } + ) : local.raw_targets - config_subscriptions = length(local.base_config_clients) > 0 ? merge([ - for client_id, data in local.base_config_clients : { + config_subscriptions = merge([ + for client_id, data in local.config_clients : { for subscription in try(data.subscriptions, []) : subscription.subscriptionId => { client_id = client_id subscription_id = subscription.subscriptionId target_ids = try(subscription.targetIds, []) } } - ]...) : {} - - all_subscriptions = local.config_subscriptions + ]...) - subscription_targets = length(local.all_subscriptions) > 0 ? merge([ - for subscription_id, subscription in local.all_subscriptions : { + subscription_targets = merge([ + for subscription_id, subscription in local.config_subscriptions : { for target_id in subscription.target_ids : "${subscription_id}-${target_id}" => { subscription_id = subscription_id target_id = target_id } } - ]...) : {} + ]...) applications_map_parameter_name = coalesce(var.applications_map_parameter_name, "/${var.project}/${var.environment}/${var.component}/applications-map") } diff --git a/infrastructure/terraform/components/callbacks/module_client_destination.tf b/infrastructure/terraform/components/callbacks/module_client_destination.tf index b3170e21..21800e94 100644 --- a/infrastructure/terraform/components/callbacks/module_client_destination.tf +++ b/infrastructure/terraform/components/callbacks/module_client_destination.tf @@ -10,8 +10,8 @@ module "client_destination" { kms_key_arn = module.kms.key_arn - targets = local.all_targets - subscriptions = local.all_subscriptions + targets = local.config_targets + subscriptions = local.config_subscriptions subscription_targets = local.subscription_targets } diff --git a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf index 9b9962f6..63b8078d 100644 --- a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf +++ b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf @@ -3,7 +3,7 @@ resource "aws_s3_object" "mock_client_config" { bucket = module.client_config_bucket.id key = "client_subscriptions/mock-it-client-subscription.json" - content = jsonencode(local.config_clients[local.mock_client_key]) + content = jsonencode(local.mock_client_config) kms_key_id = module.kms.key_arn server_side_encryption = "aws:kms" diff --git a/infrastructure/terraform/components/callbacks/sync-client-config.sh b/infrastructure/terraform/components/callbacks/sync-client-config.sh index e7a48e25..3e7d1d75 100755 --- a/infrastructure/terraform/components/callbacks/sync-client-config.sh +++ b/infrastructure/terraform/components/callbacks/sync-client-config.sh @@ -20,19 +20,18 @@ s3_prefix="client_subscriptions/" echo "Seeding client configs from s3://${bucket_name}/${s3_prefix} for ${ENVIRONMENT}/${AWS_REGION}" -aws s3 sync "s3://${bucket_name}/${s3_prefix}" "${clients_dir}/" \ +if ! sync_output=$(aws s3 sync "s3://${bucket_name}/${s3_prefix}" "${clients_dir}/" \ --region "${AWS_REGION}" \ --exclude "*" \ --include "*.json" \ - --only-show-errors - -# When deploying the mock webhook, seed the clients directory with the fixture if -# the file was not already pulled from S3 (i.e. first deploy only). Terraform -# substitutes the live Lambda URL and API key at apply time and persists the -# resolved config back to S3 via aws_s3_object so subsequent syncs pick it up. -if [[ "${DEPLOY_MOCK_WEBHOOK:-false}" == "true" ]] && [[ ! -f "${clients_dir}/mock-it-client-subscription.json" ]]; then - echo "Seeding mock integration test client subscription config from fixture" - cp "${repo_root}/tests/integration/fixtures/mock-it-client-subscription.json" "${clients_dir}/" + --only-show-errors 2>&1); then + if [[ "${sync_output}" == *"NoSuchBucket"* ]]; then + echo "Client config bucket not found yet; skipping sync for first run" + else + echo "Failed to sync client config from S3" >&2 + echo "${sync_output}" >&2 + exit 1 + fi fi # Ensure an empty directory produces a zero-length array rather than a literal "*.json" entry. From c7909a37855d289803064abe6bde62719c45bda9 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 17:26:43 +0000 Subject: [PATCH 43/55] Remove unncessary missing api key handling --- .../src/__tests__/index.test.ts | 18 ++++----- .../src/handler.ts | 40 +++++++------------ 2 files changed, 21 insertions(+), 37 deletions(-) diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts index 2006e5bf..562edf67 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -234,7 +234,7 @@ describe("Lambda handler", () => { ); }); - it("should skip targets without apiKey and still deliver if at least one valid target exists", async () => { + it("should throw when any target is missing an apiKey", async () => { const customConfigLoader = { loadClientConfig: jest.fn().mockResolvedValue( createClientSubscriptionConfig("client-abc-123", { @@ -288,16 +288,12 @@ describe("Lambda handler", () => { awsRegion: "eu-west-2", }; - const result = await handlerWithMixedTargets([sqsMessage]); - - expect(result).toHaveLength(1); - expect(result[0].signatures).not.toHaveProperty("target_no_key"); - expect(result[0].signatures).toHaveProperty( - DEFAULT_TARGET_ID.replaceAll("-", "_"), + await expect(handlerWithMixedTargets([sqsMessage])).rejects.toThrow( + "Missing apiKey for target target-no-key", ); }); - it("should filter out event when no targets have valid apiKeys", async () => { + it("should throw when no targets have valid apiKeys", async () => { const customConfigLoader = { loadClientConfig: jest.fn().mockResolvedValue( createClientSubscriptionConfig("client-abc-123", { @@ -344,9 +340,9 @@ describe("Lambda handler", () => { awsRegion: "eu-west-2", }; - const result = await handlerNoKeys([sqsMessage]); - - expect(result).toHaveLength(0); + await expect(handlerNoKeys([sqsMessage])).rejects.toThrow( + "Missing apiKey for target target-no-key", + ); }); it("should handle batch of SQS messages from EventBridge Pipes", async () => { diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts index fa4d3c4c..0d1f20b6 100644 --- a/lambdas/client-transform-filter-lambda/src/handler.ts +++ b/lambdas/client-transform-filter-lambda/src/handler.ts @@ -170,40 +170,28 @@ async function signBatch( ); const signaturesByTarget = new Map(); - let hasValidTarget = false; for (const targetId of event.targetIds) { const target = targetsById.get(targetId); const apiKey = target?.apiKey?.headerValue; - if (apiKey) { - const signature = signPayload( - event.transformedPayload, - applicationId, - apiKey, - ); - signaturesByTarget.set(targetId.replaceAll("-", "_"), signature); - observability.recordCallbackSigned( - event.transformedPayload, + if (!apiKey) { + throw new ValidationError( + `Missing apiKey for target ${targetId}`, correlationId, - clientId, - signature, - ); - hasValidTarget = true; - } else { - logger.warn( - "No apiKey for target - target will be skipped in signatures", - { clientId, correlationId, targetId }, ); } - } - - if (!hasValidTarget) { - stats.recordFiltered(); - logger.warn( - "No valid targets with apiKey - event will not be delivered", - { clientId, correlationId }, + const signature = signPayload( + event.transformedPayload, + applicationId, + apiKey, + ); + signaturesByTarget.set(targetId.replaceAll("-", "_"), signature); + observability.recordCallbackSigned( + event.transformedPayload, + correlationId, + clientId, + signature, ); - return undefined; } return { From fa2b05ccbd3162c73f9cca833fb1117af3dd976c Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 17:56:11 +0000 Subject: [PATCH 44/55] Rename client mock subscription file --- infrastructure/terraform/components/callbacks/locals.tf | 2 +- .../terraform/components/callbacks/s3_bucket_client_config.tf | 2 +- scripts/tests/integration.sh | 2 +- .../{mock-it-client-subscription.json => mock-it-client.json} | 0 tests/integration/helpers/seed-config.ts | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename tests/integration/fixtures/{mock-it-client-subscription.json => mock-it-client.json} (100%) diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf index 8f6a1110..25b2aae2 100644 --- a/infrastructure/terraform/components/callbacks/locals.tf +++ b/infrastructure/terraform/components/callbacks/locals.tf @@ -5,7 +5,7 @@ locals { root_domain_id = local.acct.route53_zone_ids["client-callbacks"] clients_dir_path = "${path.module}/../../modules/clients" - mock_client_file = "mock-it-client-subscription.json" + mock_client_file = "mock-it-client.json" mock_client_key = replace(local.mock_client_file, ".json", "") mock_client_path = "${path.module}/../../../../tests/integration/fixtures/${local.mock_client_file}" diff --git a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf index 63b8078d..2c38a526 100644 --- a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf +++ b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf @@ -2,7 +2,7 @@ resource "aws_s3_object" "mock_client_config" { count = var.deploy_mock_webhook ? 1 : 0 bucket = module.client_config_bucket.id - key = "client_subscriptions/mock-it-client-subscription.json" + key = "client_subscriptions/mock-it-client.json" content = jsonencode(local.mock_client_config) kms_key_id = module.kms.key_arn diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index defef9f9..687befcd 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -8,7 +8,7 @@ cd "$(git rev-parse --show-toplevel)" npm ci -SEED_CONFIG_FILE="$(pwd)/tests/integration/fixtures/mock-it-client-subscription.json" +SEED_CONFIG_FILE="$(pwd)/tests/integration/fixtures/mock-it-client.json" MOCK_IT_CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") CLIENT_CONFIG=$(npm run --silent clients:get -- \ diff --git a/tests/integration/fixtures/mock-it-client-subscription.json b/tests/integration/fixtures/mock-it-client.json similarity index 100% rename from tests/integration/fixtures/mock-it-client-subscription.json rename to tests/integration/fixtures/mock-it-client.json diff --git a/tests/integration/helpers/seed-config.ts b/tests/integration/helpers/seed-config.ts index 54d06f42..b073a09e 100644 --- a/tests/integration/helpers/seed-config.ts +++ b/tests/integration/helpers/seed-config.ts @@ -1,4 +1,4 @@ -import seedConfigJson from "../fixtures/mock-it-client-subscription.json"; +import seedConfigJson from "../fixtures/mock-it-client.json"; export type MockItClientConfig = typeof seedConfigJson; From d5c7e6c6bf4a376b8806634e9541698264e1bcbe Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 20:24:12 +0000 Subject: [PATCH 45/55] Improve concurrency of dlq redrive test --- tests/integration/dlq-redrive.test.ts | 31 ++++++++++++++------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/tests/integration/dlq-redrive.test.ts b/tests/integration/dlq-redrive.test.ts index 9dbdd268..f187f2e1 100644 --- a/tests/integration/dlq-redrive.test.ts +++ b/tests/integration/dlq-redrive.test.ts @@ -140,21 +140,22 @@ describe("DLQ Redrive", () => { expect(dlqPayload.data.messageId).toBe(redriveEvent.data.messageId); - const directCallbacks = await awaitSignedCallbacksFromWebhookLogGroup( - cloudWatchClient, - webhookLogGroupName, - directEvent.data.messageId, - "MessageStatus", - startTime, - ); - - const redriveCallbacks = await awaitSignedCallbacksFromWebhookLogGroup( - cloudWatchClient, - webhookLogGroupName, - redriveEvent.data.messageId, - "MessageStatus", - startTime, - ); + const [directCallbacks, redriveCallbacks] = await Promise.all([ + awaitSignedCallbacksFromWebhookLogGroup( + cloudWatchClient, + webhookLogGroupName, + directEvent.data.messageId, + "MessageStatus", + startTime, + ), + awaitSignedCallbacksFromWebhookLogGroup( + cloudWatchClient, + webhookLogGroupName, + redriveEvent.data.messageId, + "MessageStatus", + startTime, + ), + ]); await ensureInboundQueueIsEmpty(sqsClient, inboundQueueUrl); From 6c41ab43132d17e3864e6d5e4f28263820aceea4 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 20:56:47 +0000 Subject: [PATCH 46/55] Fix hardcoded region in integration script --- scripts/tests/integration.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index 687befcd..23d20a01 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -5,23 +5,27 @@ set -euo pipefail cd "$(git rev-parse --show-toplevel)" : "${ENVIRONMENT:?ENVIRONMENT must be set}" +: "${AWS_REGION:?AWS_REGION must be set}" npm ci SEED_CONFIG_FILE="$(pwd)/tests/integration/fixtures/mock-it-client.json" MOCK_IT_CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") +echo "Retrieving client config for mock client ID ${MOCK_IT_CLIENT_ID}" CLIENT_CONFIG=$(npm run --silent clients:get -- \ --client-id "${MOCK_IT_CLIENT_ID}" \ --environment "${ENVIRONMENT}" \ - --region eu-west-2) + --region "${AWS_REGION}") +echo "Client config retrieved, extracting API key" MOCK_WEBHOOK_API_KEY=$(echo "${CLIENT_CONFIG}" | jq -r '.targets[0].apiKey.headerValue') +echo "Retrieving application ID for mock client ID ${MOCK_IT_CLIENT_ID}" MOCK_APPLICATION_ID=$(npm run --silent applications-map:get -- \ --client-id "${MOCK_IT_CLIENT_ID}" \ --environment "${ENVIRONMENT}" \ - --region eu-west-2) + --region "${AWS_REGION}") export MOCK_WEBHOOK_API_KEY export MOCK_APPLICATION_ID From f4dadbeee6db7b2de46cf4396d0b77569a6d1544 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 20:57:45 +0000 Subject: [PATCH 47/55] Add lookback buffer to log search in ITs to fix issue between local clock and aws clock --- tests/integration/helpers/cloudwatch.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/integration/helpers/cloudwatch.ts b/tests/integration/helpers/cloudwatch.ts index d308c96d..37819be8 100644 --- a/tests/integration/helpers/cloudwatch.ts +++ b/tests/integration/helpers/cloudwatch.ts @@ -9,6 +9,9 @@ import { TimeoutError, waitUntil } from "async-wait-until"; const CALLBACK_WAIT_TIMEOUT_MS = 60_000; const METRICS_WAIT_TIMEOUT_MS = 60_000; const POLL_INTERVAL_MS = 2000; +const CLOUDWATCH_QUERY_LOOKBACK_MS = Number( + process.env.CLOUDWATCH_QUERY_LOOKBACK_MS ?? 120_000, +); type LogEntry = { msg: string; @@ -35,12 +38,13 @@ async function querySignedCallbacksFromWebhookLogGroup( callbackType: CallbackItem["type"], startTime: number, ): Promise { + const queryStartTime = Math.max(0, startTime - CLOUDWATCH_QUERY_LOOKBACK_MS); const filterPattern = `{ $.msg = "Callback received" && $.messageId = "${messageId}" && $.callbackType = "${callbackType}" }`; const response = await client.send( new FilterLogEventsCommand({ logGroupName, - startTime, + startTime: queryStartTime, filterPattern, }), ); @@ -103,8 +107,11 @@ export async function awaitSignedCallbacksFromWebhookLogGroup( callbackType: CallbackItem["type"], startTime: number, ): Promise { + const queryStartTime = Math.max(0, startTime - CLOUDWATCH_QUERY_LOOKBACK_MS); + const startTimeIso = new Date(startTime).toISOString(); + const queryStartTimeIso = new Date(queryStartTime).toISOString(); logger.debug( - `Waiting for callback in webhook CloudWatch log group (messageId=${messageId}, logGroup=${logGroupName})`, + `Waiting for callback in webhook CloudWatch log group (messageId=${messageId}, logGroup=${logGroupName}, startTimeIso=${startTimeIso}, queryStartTimeIso=${queryStartTimeIso}, lookbackMs=${CLOUDWATCH_QUERY_LOOKBACK_MS})`, ); return pollUntilFound( @@ -146,13 +153,14 @@ async function queryEmfMetricsFromLogGroup( metricNames: string[], startTime: number, ): Promise> { + const queryStartTime = Math.max(0, startTime - CLOUDWATCH_QUERY_LOOKBACK_MS); const conditions = metricNames.map((name) => `$.${name} > 0`).join(" || "); const filterPattern = `{ ${conditions} }`; const response = await client.send( new FilterLogEventsCommand({ logGroupName, - startTime, + startTime: queryStartTime, filterPattern, }), ); @@ -172,8 +180,11 @@ export async function awaitAllEmfMetricsInLogGroup( metricNames: string[], startTime: number, ): Promise { + const queryStartTime = Math.max(0, startTime - CLOUDWATCH_QUERY_LOOKBACK_MS); + const queryStartTimeIso = new Date(queryStartTime).toISOString(); + const startTimeIso = new Date(startTime).toISOString(); logger.debug( - `Waiting for EMF metrics in CloudWatch log group (metrics=${metricNames.join(",")}, logGroup=${logGroupName})`, + `Waiting for EMF metrics in CloudWatch log group (metrics=${metricNames.join(",")}, logGroup=${logGroupName}, startTimeIso=${startTimeIso}, queryStartTimeIso=${queryStartTimeIso}, lookbackMs=${CLOUDWATCH_QUERY_LOOKBACK_MS})`, ); await waitUntil( From 50b07cf6d5ab102d19345b38450991fc688cb464 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 21:01:16 +0000 Subject: [PATCH 48/55] Remove superfluous test --- .../src/__tests__/index.test.ts | 52 ------------------- 1 file changed, 52 deletions(-) diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts index 562edf67..5c6d495c 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -293,58 +293,6 @@ describe("Lambda handler", () => { ); }); - it("should throw when no targets have valid apiKeys", async () => { - const customConfigLoader = { - loadClientConfig: jest.fn().mockResolvedValue( - createClientSubscriptionConfig("client-abc-123", { - subscriptions: [ - createMessageStatusSubscription(["DELIVERED"], { - targetIds: ["target-no-key"], - }), - ], - targets: [ - createTarget({ - targetId: "target-no-key", - apiKey: undefined as unknown as { - headerName: string; - headerValue: string; - }, - }), - ], - }), - ), - } as unknown as ConfigLoader; - - const handlerNoKeys = createHandler({ - createObservabilityService: () => - new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger), - createConfigLoaderService: () => - ({ getLoader: () => customConfigLoader }) as ConfigLoaderService, - createApplicationsMapService: makeStubApplicationsMapService, - }); - - const sqsMessage: SQSRecord = { - messageId: "sqs-msg-id-nokey", - receiptHandle: "receipt-handle-nokey", - body: JSON.stringify(validMessageStatusEvent), - attributes: { - ApproximateReceiveCount: "1", - SentTimestamp: "1519211230", - SenderId: "ABCDEFGHIJ", - ApproximateFirstReceiveTimestamp: "1519211230", - }, - messageAttributes: {}, - md5OfBody: "mock-md5", - eventSource: "aws:sqs", - eventSourceARN: "arn:aws:sqs:eu-west-2:123456789:mock-queue", - awsRegion: "eu-west-2", - }; - - await expect(handlerNoKeys([sqsMessage])).rejects.toThrow( - "Missing apiKey for target target-no-key", - ); - }); - it("should handle batch of SQS messages from EventBridge Pipes", async () => { const sqsMessages: SQSRecord[] = [ { From aab93642f53ee3ad28a5721bef677e25dbc00bff Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Wed, 25 Mar 2026 21:06:30 +0000 Subject: [PATCH 49/55] Delete global setup/teardown from ITs that is no longer needed --- tests/integration/jest.config.ts | 2 -- tests/integration/jest.global-setup.ts | 4 ---- tests/integration/jest.global-teardown.ts | 4 ---- 3 files changed, 10 deletions(-) delete mode 100644 tests/integration/jest.global-setup.ts delete mode 100644 tests/integration/jest.global-teardown.ts diff --git a/tests/integration/jest.config.ts b/tests/integration/jest.config.ts index fd9a3fa4..c4c673ed 100644 --- a/tests/integration/jest.config.ts +++ b/tests/integration/jest.config.ts @@ -3,8 +3,6 @@ import { nodeJestConfig } from "../../jest.config.base"; export default { ...nodeJestConfig, modulePaths: [""], - globalSetup: "/jest.global-setup.ts", - globalTeardown: "/jest.global-teardown.ts", coveragePathIgnorePatterns: [ ...(nodeJestConfig.coveragePathIgnorePatterns ?? []), "/helpers/", diff --git a/tests/integration/jest.global-setup.ts b/tests/integration/jest.global-setup.ts deleted file mode 100644 index b083e798..00000000 --- a/tests/integration/jest.global-setup.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default async function globalSetup() { - // No setup actions are required for client subscription config. - // Config is now loaded via tooling before terraform apply. -} diff --git a/tests/integration/jest.global-teardown.ts b/tests/integration/jest.global-teardown.ts deleted file mode 100644 index fb78cebe..00000000 --- a/tests/integration/jest.global-teardown.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default async function globalTeardown() { - // No teardown actions are required for client subscription config. - // Config is now loaded via tooling before terraform apply. -} From 313ba6698243b11e3508b2d9098e7ef116f7d5e5 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 26 Mar 2026 11:45:33 +0000 Subject: [PATCH 50/55] Force destroy config bucket if mock web hook deployed --- .../terraform/components/callbacks/s3_bucket_client_config.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf index 2c38a526..093f896d 100644 --- a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf +++ b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf @@ -30,7 +30,7 @@ module "client_config_bucket" { ) kms_key_arn = module.kms.key_arn - force_destroy = false + force_destroy = var.deploy_mock_webhook versioning = true object_ownership = "BucketOwnerPreferred" bucket_key_enabled = true From ec282bdfc83c28dd0535a5c4d11ac26f9bd6ed6d Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 26 Mar 2026 14:19:12 +0000 Subject: [PATCH 51/55] Variable to force destroy the config bucket --- infrastructure/terraform/components/callbacks/README.md | 1 + .../components/callbacks/s3_bucket_client_config.tf | 2 +- infrastructure/terraform/components/callbacks/variables.tf | 6 ++++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/callbacks/README.md b/infrastructure/terraform/components/callbacks/README.md index 863bde34..125205d8 100644 --- a/infrastructure/terraform/components/callbacks/README.md +++ b/infrastructure/terraform/components/callbacks/README.md @@ -15,6 +15,7 @@ |------|-------------|------|---------|:--------:| | [applications\_map\_parameter\_name](#input\_applications\_map\_parameter\_name) | SSM Parameter Store path for the clientId-to-applicationData map, where applicationData is currently only the applicationId | `string` | `null` | no | | [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes | +| [client\_config\_bucket\_force\_destroy](#input\_client\_config\_bucket\_force\_destroy) | Force-delete all objects and versions from the client config bucket during destroy | `bool` | `false` | no | | [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"callbacks"` | no | | [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | | [deploy\_mock\_webhook](#input\_deploy\_mock\_webhook) | Flag to deploy mock webhook lambda for integration testing (test/dev environments only) | `bool` | `false` | no | diff --git a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf index 093f896d..d767f2c0 100644 --- a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf +++ b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf @@ -30,7 +30,7 @@ module "client_config_bucket" { ) kms_key_arn = module.kms.key_arn - force_destroy = var.deploy_mock_webhook + force_destroy = var.client_config_bucket_force_destroy versioning = true object_ownership = "BucketOwnerPreferred" bucket_key_enabled = true diff --git a/infrastructure/terraform/components/callbacks/variables.tf b/infrastructure/terraform/components/callbacks/variables.tf index 6003d9e2..deffdbae 100644 --- a/infrastructure/terraform/components/callbacks/variables.tf +++ b/infrastructure/terraform/components/callbacks/variables.tf @@ -155,6 +155,12 @@ variable "deploy_mock_webhook" { default = false } +variable "client_config_bucket_force_destroy" { + type = bool + description = "Force-delete all objects and versions from the client config bucket during destroy" + default = false +} + variable "message_root_uri" { type = string description = "The root URI used for constructing message links in callback payloads" From 3c3abd3e9bb550f814caee0714955bdc7c5a0436 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 26 Mar 2026 14:27:04 +0000 Subject: [PATCH 52/55] Rename deploy_mock_webhook var --- .../terraform/components/callbacks/README.md | 2 +- .../terraform/components/callbacks/locals.tf | 8 ++++---- .../callbacks/module_mock_webhook_lambda.tf | 12 ++++++------ .../terraform/components/callbacks/outputs.tf | 4 ++-- .../components/callbacks/s3_bucket_client_config.tf | 2 +- .../callbacks/ssm_parameter_applications_map.tf | 2 +- .../terraform/components/callbacks/variables.tf | 2 +- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/infrastructure/terraform/components/callbacks/README.md b/infrastructure/terraform/components/callbacks/README.md index 125205d8..790c43ec 100644 --- a/infrastructure/terraform/components/callbacks/README.md +++ b/infrastructure/terraform/components/callbacks/README.md @@ -18,7 +18,7 @@ | [client\_config\_bucket\_force\_destroy](#input\_client\_config\_bucket\_force\_destroy) | Force-delete all objects and versions from the client config bucket during destroy | `bool` | `false` | no | | [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"callbacks"` | no | | [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | -| [deploy\_mock\_webhook](#input\_deploy\_mock\_webhook) | Flag to deploy mock webhook lambda for integration testing (test/dev environments only) | `bool` | `false` | no | +| [deploy\_mock\_client\_subscriptions](#input\_deploy\_mock\_client\_subscriptions) | Flag to deploy mock webhook lambda for integration testing (test/dev environments only) | `bool` | `false` | no | | [enable\_event\_anomaly\_detection](#input\_enable\_event\_anomaly\_detection) | Enable CloudWatch anomaly detection alarm for inbound event queue message reception | `bool` | `true` | no | | [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes | | [event\_anomaly\_band\_width](#input\_event\_anomaly\_band\_width) | The width of the anomaly detection band. Higher values (e.g. 4-6) reduce sensitivity and noise, lower values (e.g. 2-3) increase sensitivity. Recommended: 2-4. | `number` | `3` | no | diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf index 25b2aae2..054df468 100644 --- a/infrastructure/terraform/components/callbacks/locals.tf +++ b/infrastructure/terraform/components/callbacks/locals.tf @@ -10,7 +10,7 @@ locals { mock_client_path = "${path.module}/../../../../tests/integration/fixtures/${local.mock_client_file}" discovered_config_files = fileset(local.clients_dir_path, "*.json") - config_files = var.deploy_mock_webhook ? setunion( + config_files = var.deploy_mock_client_subscriptions ? setunion( local.discovered_config_files, toset([local.mock_client_file]) ) : local.discovered_config_files @@ -27,10 +27,10 @@ locals { } ]...) - mock_client_id = var.deploy_mock_webhook ? local.config_clients[local.mock_client_key].clientId : "" + mock_client_id = var.deploy_mock_client_subscriptions ? local.config_clients[local.mock_client_key].clientId : "" # Enriched mock client config with live Lambda URL and API key (for S3 upload) - mock_client_config = var.deploy_mock_webhook ? merge( + mock_client_config = var.deploy_mock_client_subscriptions ? merge( local.config_clients[local.mock_client_key], { targets = [ @@ -58,7 +58,7 @@ locals { ]...) # Override mock targets with live Lambda URL when deployed - config_targets = var.deploy_mock_webhook ? merge( + config_targets = var.deploy_mock_client_subscriptions ? merge( local.raw_targets, { for target_id, target in local.raw_targets : target_id => merge(target, { diff --git a/infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf b/infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf index 9a6de177..1025a99f 100644 --- a/infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf +++ b/infrastructure/terraform/components/callbacks/module_mock_webhook_lambda.tf @@ -1,5 +1,5 @@ module "mock_webhook_lambda" { - count = var.deploy_mock_webhook ? 1 : 0 + count = var.deploy_mock_client_subscriptions ? 1 : 0 source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip" function_name = "mock-webhook" @@ -42,13 +42,13 @@ module "mock_webhook_lambda" { } resource "random_password" "mock_webhook_api_key" { - count = var.deploy_mock_webhook ? 1 : 0 + count = var.deploy_mock_client_subscriptions ? 1 : 0 length = 32 special = false } data "aws_iam_policy_document" "mock_webhook_lambda" { - count = var.deploy_mock_webhook ? 1 : 0 + count = var.deploy_mock_client_subscriptions ? 1 : 0 statement { sid = "KMSPermissions" @@ -67,7 +67,7 @@ data "aws_iam_policy_document" "mock_webhook_lambda" { # Lambda Function URL for mock webhook (test/dev only) resource "aws_lambda_function_url" "mock_webhook" { - count = var.deploy_mock_webhook ? 1 : 0 + count = var.deploy_mock_client_subscriptions ? 1 : 0 function_name = module.mock_webhook_lambda[0].function_name authorization_type = "NONE" # Public endpoint for testing @@ -80,7 +80,7 @@ resource "aws_lambda_function_url" "mock_webhook" { } resource "aws_lambda_permission" "mock_webhook_function_url" { - count = var.deploy_mock_webhook ? 1 : 0 + count = var.deploy_mock_client_subscriptions ? 1 : 0 statement_id_prefix = "FunctionURLAllowPublicAccess" action = "lambda:InvokeFunctionUrl" function_name = module.mock_webhook_lambda[0].function_name @@ -89,7 +89,7 @@ resource "aws_lambda_permission" "mock_webhook_function_url" { } resource "aws_lambda_permission" "mock_webhook_function_invoke" { - count = var.deploy_mock_webhook ? 1 : 0 + count = var.deploy_mock_client_subscriptions ? 1 : 0 statement_id_prefix = "FunctionURLAllowInvokeAction" action = "lambda:InvokeFunction" function_name = module.mock_webhook_lambda[0].function_name diff --git a/infrastructure/terraform/components/callbacks/outputs.tf b/infrastructure/terraform/components/callbacks/outputs.tf index 3daaa8b2..04946d02 100644 --- a/infrastructure/terraform/components/callbacks/outputs.tf +++ b/infrastructure/terraform/components/callbacks/outputs.tf @@ -20,10 +20,10 @@ output "deployment" { output "mock_webhook_lambda_log_group_name" { description = "CloudWatch log group name for mock webhook lambda (for integration test queries)" - value = var.deploy_mock_webhook ? module.mock_webhook_lambda[0].cloudwatch_log_group_name : null + value = var.deploy_mock_client_subscriptions ? module.mock_webhook_lambda[0].cloudwatch_log_group_name : null } output "mock_webhook_url" { description = "URL endpoint for mock webhook (for TEST_WEBHOOK_URL environment variable)" - value = var.deploy_mock_webhook ? aws_lambda_function_url.mock_webhook[0].function_url : null + value = var.deploy_mock_client_subscriptions ? aws_lambda_function_url.mock_webhook[0].function_url : null } diff --git a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf index d767f2c0..b02857c2 100644 --- a/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf +++ b/infrastructure/terraform/components/callbacks/s3_bucket_client_config.tf @@ -1,5 +1,5 @@ resource "aws_s3_object" "mock_client_config" { - count = var.deploy_mock_webhook ? 1 : 0 + count = var.deploy_mock_client_subscriptions ? 1 : 0 bucket = module.client_config_bucket.id key = "client_subscriptions/mock-it-client.json" diff --git a/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf b/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf index 93f38c45..d8a883f5 100644 --- a/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf +++ b/infrastructure/terraform/components/callbacks/ssm_parameter_applications_map.tf @@ -3,7 +3,7 @@ resource "aws_ssm_parameter" "applications_map" { type = "SecureString" key_id = module.kms.key_arn - value = var.deploy_mock_webhook ? jsonencode({ + value = var.deploy_mock_client_subscriptions ? jsonencode({ (local.mock_client_id) = "mock-application-id" }) : jsonencode({}) diff --git a/infrastructure/terraform/components/callbacks/variables.tf b/infrastructure/terraform/components/callbacks/variables.tf index deffdbae..9680b5c4 100644 --- a/infrastructure/terraform/components/callbacks/variables.tf +++ b/infrastructure/terraform/components/callbacks/variables.tf @@ -149,7 +149,7 @@ variable "event_anomaly_band_width" { } } -variable "deploy_mock_webhook" { +variable "deploy_mock_client_subscriptions" { type = bool description = "Flag to deploy mock webhook lambda for integration testing (test/dev environments only)" default = false From 14b74c7c3f72065fc2a1a32a5a40704b286fcb05 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 26 Mar 2026 16:23:44 +0000 Subject: [PATCH 53/55] Feedback: resolve coverage issue in mock --- lambdas/mock-webhook-lambda/jest.config.ts | 2 +- lambdas/mock-webhook-lambda/src/index.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lambdas/mock-webhook-lambda/jest.config.ts b/lambdas/mock-webhook-lambda/jest.config.ts index 3eb254b6..017ed2db 100644 --- a/lambdas/mock-webhook-lambda/jest.config.ts +++ b/lambdas/mock-webhook-lambda/jest.config.ts @@ -5,7 +5,7 @@ export default { coverageThreshold: { global: { ...nodeJestConfig.coverageThreshold?.global, - branches: 93, + branches: 100, lines: 100, statements: 100, }, diff --git a/lambdas/mock-webhook-lambda/src/index.ts b/lambdas/mock-webhook-lambda/src/index.ts index 3d74a86a..08075c81 100644 --- a/lambdas/mock-webhook-lambda/src/index.ts +++ b/lambdas/mock-webhook-lambda/src/index.ts @@ -46,9 +46,7 @@ async function buildResponse( logger.info("Mock webhook invoked", { path: event.path ?? eventWithFunctionUrlFields.rawPath, - method: - event.httpMethod ?? - eventWithFunctionUrlFields.requestContext?.http?.method, + method: event.httpMethod, hasBody: Boolean(event.body), "x-api-key": headers["x-api-key"], "x-hmac-sha256-signature": headers["x-hmac-sha256-signature"], @@ -123,7 +121,7 @@ async function buildResponse( correlationId, messageId, callbackType: item.type, - apiKey: headers["x-api-key"] ?? "", + apiKey: providedApiKey, signature: headers["x-hmac-sha256-signature"] ?? "", payload: JSON.stringify(item), }); From 7bf9fb8b836fafa56370b92a6ef4bf55869062a2 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Thu, 26 Mar 2026 17:14:17 +0000 Subject: [PATCH 54/55] Clarify seed terraform --- .../terraform/components/callbacks/locals.tf | 6 +++--- .../components/callbacks/sync-client-config.sh | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf index 054df468..b4ea625c 100644 --- a/infrastructure/terraform/components/callbacks/locals.tf +++ b/infrastructure/terraform/components/callbacks/locals.tf @@ -9,11 +9,11 @@ locals { mock_client_key = replace(local.mock_client_file, ".json", "") mock_client_path = "${path.module}/../../../../tests/integration/fixtures/${local.mock_client_file}" - discovered_config_files = fileset(local.clients_dir_path, "*.json") + config_files_from_s3 = fileset(local.clients_dir_path, "*.json") config_files = var.deploy_mock_client_subscriptions ? setunion( - local.discovered_config_files, + local.config_files_from_s3, toset([local.mock_client_file]) - ) : local.discovered_config_files + ) : local.config_files_from_s3 config_clients = merge([ for filename in local.config_files : { diff --git a/infrastructure/terraform/components/callbacks/sync-client-config.sh b/infrastructure/terraform/components/callbacks/sync-client-config.sh index 3e7d1d75..9a84cef7 100755 --- a/infrastructure/terraform/components/callbacks/sync-client-config.sh +++ b/infrastructure/terraform/components/callbacks/sync-client-config.sh @@ -1,5 +1,20 @@ #!/usr/bin/env bash +# This script seeds local client subscription JSON files from the config bucket +# before Terraform evaluates locals/fileset (see local.config_clients in locals.tf). +# It handles the S3 bucket not existing on first apply. +# Deployment Lifecycle: +# - Sync client JSON files from S3 into "modules/clients" before Terraform runs. +# - Terraform then reads the local fileset from "modules/clients" as input. +# - On later deployments, files are refreshed from bucket state each run. +# +# Deployment Lifecycle dev/test environment: +# - Special handling is needed to seed test data and have it take effect on the first apply, as the bucket and files won't exist yet +# - Terraform merges in the local mock fixture config into local.config_clients +# - Terraform uploads mock-it-client.json to the config bucket +# - On subsequent deployments, that mock config is synced from S3 and handled +# through the normal fileset path. + set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -26,6 +41,7 @@ if ! sync_output=$(aws s3 sync "s3://${bucket_name}/${s3_prefix}" "${clients_dir --include "*.json" \ --only-show-errors 2>&1); then if [[ "${sync_output}" == *"NoSuchBucket"* ]]; then + # Expected on first apply before Terraform creates the bucket. echo "Client config bucket not found yet; skipping sync for first run" else echo "Failed to sync client config from S3" >&2 From 284831eb99f9d1dcef950c1ee9d6899bb516d802 Mon Sep 17 00:00:00 2001 From: Mike Wild Date: Fri, 27 Mar 2026 12:16:08 +0000 Subject: [PATCH 55/55] Refactor the env var setting from integration.sh --- scripts/tests/integration-env.sh | 24 ++++++++++++++++++++++++ scripts/tests/integration.sh | 24 +----------------------- 2 files changed, 25 insertions(+), 23 deletions(-) create mode 100644 scripts/tests/integration-env.sh diff --git a/scripts/tests/integration-env.sh b/scripts/tests/integration-env.sh new file mode 100644 index 00000000..b9262cb9 --- /dev/null +++ b/scripts/tests/integration-env.sh @@ -0,0 +1,24 @@ +#!/bin/bash +: "${ENVIRONMENT:?ENVIRONMENT must be set}" +: "${AWS_REGION:?AWS_REGION must be set}" + +SEED_CONFIG_FILE="$(pwd)/tests/integration/fixtures/mock-it-client.json" +MOCK_IT_CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") + +echo "Retrieving client config for mock client ID ${MOCK_IT_CLIENT_ID}" +CLIENT_CONFIG=$(npm run --silent clients:get -- \ + --client-id "${MOCK_IT_CLIENT_ID}" \ + --environment "${ENVIRONMENT}" \ + --region "${AWS_REGION}") + +echo "Client config retrieved, extracting API key" +MOCK_WEBHOOK_API_KEY=$(echo "${CLIENT_CONFIG}" | jq -r '.targets[0].apiKey.headerValue') + +echo "Retrieving application ID for mock client ID ${MOCK_IT_CLIENT_ID}" +MOCK_APPLICATION_ID=$(npm run --silent applications-map:get -- \ + --client-id "${MOCK_IT_CLIENT_ID}" \ + --environment "${ENVIRONMENT}" \ + --region "${AWS_REGION}") + +export MOCK_WEBHOOK_API_KEY +export MOCK_APPLICATION_ID diff --git a/scripts/tests/integration.sh b/scripts/tests/integration.sh index 23d20a01..fcc89389 100755 --- a/scripts/tests/integration.sh +++ b/scripts/tests/integration.sh @@ -4,30 +4,8 @@ set -euo pipefail cd "$(git rev-parse --show-toplevel)" -: "${ENVIRONMENT:?ENVIRONMENT must be set}" -: "${AWS_REGION:?AWS_REGION must be set}" - npm ci -SEED_CONFIG_FILE="$(pwd)/tests/integration/fixtures/mock-it-client.json" -MOCK_IT_CLIENT_ID=$(jq -r '.clientId' "${SEED_CONFIG_FILE}") - -echo "Retrieving client config for mock client ID ${MOCK_IT_CLIENT_ID}" -CLIENT_CONFIG=$(npm run --silent clients:get -- \ - --client-id "${MOCK_IT_CLIENT_ID}" \ - --environment "${ENVIRONMENT}" \ - --region "${AWS_REGION}") - -echo "Client config retrieved, extracting API key" -MOCK_WEBHOOK_API_KEY=$(echo "${CLIENT_CONFIG}" | jq -r '.targets[0].apiKey.headerValue') - -echo "Retrieving application ID for mock client ID ${MOCK_IT_CLIENT_ID}" -MOCK_APPLICATION_ID=$(npm run --silent applications-map:get -- \ - --client-id "${MOCK_IT_CLIENT_ID}" \ - --environment "${ENVIRONMENT}" \ - --region "${AWS_REGION}") - -export MOCK_WEBHOOK_API_KEY -export MOCK_APPLICATION_ID +source ./scripts/tests/integration-env.sh npm run test:integration