From d9dfecc5e05998d3fbb707775a8275b9849468b7 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Tue, 14 Apr 2026 21:06:12 +0100 Subject: [PATCH 1/2] CCM-16073 - Enhanced callbacks [skip ci] --- .gitleaksignore | 1 + eslint.config.mjs | 3 +- .../terraform/components/callbacks/README.md | 10 +- .../callbacks/cloudwatch_eventbus_main.tf | 6 + .../cloudwatch_metric_alarm_dlq_depth.tf | 31 -- .../callbacks/elasticache_delivery_state.tf | 178 +++++++ .../terraform/components/callbacks/locals.tf | 48 +- .../callbacks/module_client_delivery.tf | 47 ++ .../callbacks/module_client_destination.tf | 17 - .../callbacks/module_mock_webhook_alb_mtls.tf | 111 ++++ .../components/callbacks/pipes_pipe_main.tf | 3 +- .../components/callbacks/variables.tf | 38 +- .../modules/client-delivery/README.md | 69 +++ .../cloudwatch_event_rule_per_subscription.tf | 101 ++++ .../client-delivery/iam_role_sqs_target.tf | 115 +++++ .../modules/client-delivery/locals.tf | 21 + .../client-delivery/module_dlq_per_client.tf | 43 ++ .../module_https_client_lambda.tf | 70 +++ .../client-delivery/module_sqs_per_client.tf | 39 ++ .../modules/client-delivery/outputs.tf | 34 ++ .../modules/client-delivery/variables.tf | 212 ++++++++ .../modules/client-destination/README.md | 32 -- .../cloudwatch_event_api_destination_this.tf | 10 - .../cloudwatch_event_connection_main.tf | 14 - .../cloudwatch_event_rule_main.tf | 46 -- .../iam_role_api_target_role.tf | 83 --- .../modules/client-destination/locals.tf | 12 - .../client-destination/module_target_dlq.tf | 41 -- .../modules/client-destination/variables.tf | 67 --- knip.ts | 7 + .../package.json | 1 + .../helpers/client-subscription-fixtures.ts | 2 + .../src/__tests__/index.component.test.ts | 29 +- .../src/__tests__/index.test.ts | 75 --- .../__tests__/services/config-cache.test.ts | 2 +- .../__tests__/services/config-loader.test.ts | 2 +- .../services/config-update.component.test.ts | 2 +- .../__tests__/services/payload-signer.test.ts | 49 -- .../src/handler.ts | 116 +---- .../src/index.ts | 19 +- .../src/services/config-loader-service.ts | 2 +- .../src/services/config-loader.ts | 2 +- .../src/services/observability.ts | 14 +- lambdas/https-client-lambda/jest.config.ts | 9 + lambdas/https-client-lambda/lua-transform.js | 7 + lambdas/https-client-lambda/package.json | 36 ++ .../src/__tests__/config-loader.test.ts | 139 +++++ .../src/__tests__/delivery-metrics.test.ts | 174 +++++++ .../src/__tests__/dlq-sender.test.ts | 57 +++ .../src/__tests__/endpoint-gate.test.ts | 278 ++++++++++ .../src/__tests__/handler.test.ts | 474 ++++++++++++++++++ .../src/__tests__/https-client.test.ts | 211 ++++++++ .../src/__tests__/index.test.ts | 36 ++ .../src/__tests__/payload-signer.test.ts | 39 ++ .../src/__tests__/retry-policy.test.ts | 84 ++++ .../src/__tests__/sqs-visibility.test.ts | 71 +++ .../__tests__/ssm-applications-map.test.ts | 117 +++++ .../src/__tests__/tls-agent-factory.test.ts | 338 +++++++++++++ lambdas/https-client-lambda/src/handler.ts | 213 ++++++++ lambdas/https-client-lambda/src/index.ts | 7 + lambdas/https-client-lambda/src/lua.d.ts | 4 + .../src/services/admit.lua | 95 ++++ .../src/services/config-loader.ts | 71 +++ .../src/services/delivery-metrics.ts | 66 +++ .../src/services/delivery/https-client.ts | 84 ++++ .../src/services/delivery/retry-policy.ts | 34 ++ .../services/delivery/tls-agent-factory.ts | 193 +++++++ .../src/services/dlq-sender.ts | 17 + .../src/services/endpoint-gate.ts | 179 +++++++ .../src/services/logger.ts | 1 + .../src/services/payload-signer.ts | 2 +- .../src/services/record-result.lua | 92 ++++ .../src/services/sqs-visibility.ts | 21 + .../src/services/ssm-applications-map.ts | 62 +++ lambdas/https-client-lambda/tsconfig.json | 14 + .../src/__tests__/index.test.ts | 137 +++++ lambdas/mock-webhook-lambda/src/index.ts | 50 +- pnpm-lock.yaml | 307 +++++++++++- pnpm-workspace.yaml | 3 + scripts/config/pre-commit.yaml | 1 + src/config-cache/jest.config.ts | 14 + src/config-cache/package.json | 32 ++ .../src/__tests__/config-cache.test.ts | 75 +++ .../config-cache/src}/config-cache.ts | 0 src/config-cache/src/index.ts | 1 + src/config-cache/tsconfig.json | 14 + .../__tests__/client-config-schema.test.ts | 105 ++++ src/models/src/client-config-schema.ts | 28 ++ src/models/src/client-config.ts | 13 + .../fixtures/subscriptions/mock-client-1.json | 6 + .../fixtures/subscriptions/mock-client-2.json | 12 + .../mock-client-circuit-breaker.json | 40 ++ .../subscriptions/mock-client-mtls.json | 36 ++ .../subscriptions/mock-client-rate-limit.json | 35 ++ tests/integration/helpers/event-factories.ts | 32 ++ .../integration/helpers/mock-client-config.ts | 5 + .../package.json | 1 + .../client-subscription-builder.test.ts | 79 +++ .../cli/targets-set-certificate.test.ts | 155 ++++++ .../entrypoint/cli/targets-set-mtls.test.ts | 107 ++++ .../cli/targets-set-pinning.test.ts | 133 +++++ .../helpers/client-subscription-fixtures.ts | 2 + .../src/domain/client-subscription-builder.ts | 30 ++ .../src/entrypoint/cli/clients-put.ts | 1 - .../entrypoint/cli/targets-set-certificate.ts | 94 ++++ .../src/entrypoint/cli/targets-set-mtls.ts | 101 ++++ .../src/entrypoint/cli/targets-set-pinning.ts | 100 ++++ 107 files changed, 6092 insertions(+), 706 deletions(-) delete mode 100644 infrastructure/terraform/components/callbacks/cloudwatch_metric_alarm_dlq_depth.tf create mode 100644 infrastructure/terraform/components/callbacks/elasticache_delivery_state.tf create mode 100644 infrastructure/terraform/components/callbacks/module_client_delivery.tf delete mode 100644 infrastructure/terraform/components/callbacks/module_client_destination.tf create mode 100644 infrastructure/terraform/components/callbacks/module_mock_webhook_alb_mtls.tf create mode 100644 infrastructure/terraform/modules/client-delivery/README.md create mode 100644 infrastructure/terraform/modules/client-delivery/cloudwatch_event_rule_per_subscription.tf create mode 100644 infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf create mode 100644 infrastructure/terraform/modules/client-delivery/locals.tf create mode 100644 infrastructure/terraform/modules/client-delivery/module_dlq_per_client.tf create mode 100644 infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf create mode 100644 infrastructure/terraform/modules/client-delivery/module_sqs_per_client.tf create mode 100644 infrastructure/terraform/modules/client-delivery/outputs.tf create mode 100644 infrastructure/terraform/modules/client-delivery/variables.tf delete mode 100644 infrastructure/terraform/modules/client-destination/README.md delete mode 100644 infrastructure/terraform/modules/client-destination/cloudwatch_event_api_destination_this.tf delete mode 100644 infrastructure/terraform/modules/client-destination/cloudwatch_event_connection_main.tf delete mode 100644 infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf delete mode 100644 infrastructure/terraform/modules/client-destination/iam_role_api_target_role.tf delete mode 100644 infrastructure/terraform/modules/client-destination/locals.tf delete mode 100644 infrastructure/terraform/modules/client-destination/module_target_dlq.tf delete mode 100644 infrastructure/terraform/modules/client-destination/variables.tf delete mode 100644 lambdas/client-transform-filter-lambda/src/__tests__/services/payload-signer.test.ts create mode 100644 lambdas/https-client-lambda/jest.config.ts create mode 100644 lambdas/https-client-lambda/lua-transform.js create mode 100644 lambdas/https-client-lambda/package.json create mode 100644 lambdas/https-client-lambda/src/__tests__/config-loader.test.ts create mode 100644 lambdas/https-client-lambda/src/__tests__/delivery-metrics.test.ts create mode 100644 lambdas/https-client-lambda/src/__tests__/dlq-sender.test.ts create mode 100644 lambdas/https-client-lambda/src/__tests__/endpoint-gate.test.ts create mode 100644 lambdas/https-client-lambda/src/__tests__/handler.test.ts create mode 100644 lambdas/https-client-lambda/src/__tests__/https-client.test.ts create mode 100644 lambdas/https-client-lambda/src/__tests__/index.test.ts create mode 100644 lambdas/https-client-lambda/src/__tests__/payload-signer.test.ts create mode 100644 lambdas/https-client-lambda/src/__tests__/retry-policy.test.ts create mode 100644 lambdas/https-client-lambda/src/__tests__/sqs-visibility.test.ts create mode 100644 lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts create mode 100644 lambdas/https-client-lambda/src/__tests__/tls-agent-factory.test.ts create mode 100644 lambdas/https-client-lambda/src/handler.ts create mode 100644 lambdas/https-client-lambda/src/index.ts create mode 100644 lambdas/https-client-lambda/src/lua.d.ts create mode 100644 lambdas/https-client-lambda/src/services/admit.lua create mode 100644 lambdas/https-client-lambda/src/services/config-loader.ts create mode 100644 lambdas/https-client-lambda/src/services/delivery-metrics.ts create mode 100644 lambdas/https-client-lambda/src/services/delivery/https-client.ts create mode 100644 lambdas/https-client-lambda/src/services/delivery/retry-policy.ts create mode 100644 lambdas/https-client-lambda/src/services/delivery/tls-agent-factory.ts create mode 100644 lambdas/https-client-lambda/src/services/dlq-sender.ts create mode 100644 lambdas/https-client-lambda/src/services/endpoint-gate.ts create mode 100644 lambdas/https-client-lambda/src/services/logger.ts rename lambdas/{client-transform-filter-lambda => https-client-lambda}/src/services/payload-signer.ts (100%) create mode 100644 lambdas/https-client-lambda/src/services/record-result.lua create mode 100644 lambdas/https-client-lambda/src/services/sqs-visibility.ts create mode 100644 lambdas/https-client-lambda/src/services/ssm-applications-map.ts create mode 100644 lambdas/https-client-lambda/tsconfig.json create mode 100644 src/config-cache/jest.config.ts create mode 100644 src/config-cache/package.json create mode 100644 src/config-cache/src/__tests__/config-cache.test.ts rename {lambdas/client-transform-filter-lambda/src/services => src/config-cache/src}/config-cache.ts (100%) create mode 100644 src/config-cache/src/index.ts create mode 100644 src/config-cache/tsconfig.json create mode 100644 tests/integration/fixtures/subscriptions/mock-client-circuit-breaker.json create mode 100644 tests/integration/fixtures/subscriptions/mock-client-mtls.json create mode 100644 tests/integration/fixtures/subscriptions/mock-client-rate-limit.json create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-certificate.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-mtls.test.ts create mode 100644 tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-pinning.test.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/targets-set-certificate.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/targets-set-mtls.ts create mode 100644 tools/client-subscriptions-management/src/entrypoint/cli/targets-set-pinning.ts diff --git a/.gitleaksignore b/.gitleaksignore index 3356f79f..b2c82098 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -4,3 +4,4 @@ cd9c0efec38c5d63053dd865e5d4e207c0760d91:docs/guides/Perform_static_analysis.md: cd9c0efec38c5d63053dd865e5d4e207c0760d91:docs/guides/Perform_static_analysis.md:sonar-api-token:37 96096685ab3d6876671e2bc9a6ff4d48fc56e521:src/helloworld/helloworld.sln:ipv4:4 4f4e8c15629b2cb09356a7fed4d72953590227ce:docs/Gemfile.lock:ipv4:4 +231b9cb259d92c3defc27de00a4196682d11c231:lambdas/https-client-lambda/src/__tests__/tls-agent-factory.test.ts:private-key:49 diff --git a/eslint.config.mjs b/eslint.config.mjs index eb59432b..9ea6c3e0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,6 +28,7 @@ export default defineConfig([ "**/test-results", "**/playwright-report*", "eslint.config.mjs", + "**/lua-transform.js", ]), //imports @@ -200,7 +201,7 @@ export default defineConfig([ }, }, { - files: ["**/utils/**", "tests/test-team/**", "tests/performance/helpers/**", "lambdas/**/src/**"], + files: ["**/utils/**", "tests/test-team/**", "tests/performance/helpers/**", "lambdas/**/src/**", "src/**/src/**"], rules: { "import-x/prefer-default-export": 0, }, diff --git a/infrastructure/terraform/components/callbacks/README.md b/infrastructure/terraform/components/callbacks/README.md index b1587725..49d1372b 100644 --- a/infrastructure/terraform/components/callbacks/README.md +++ b/infrastructure/terraform/components/callbacks/README.md @@ -19,7 +19,7 @@ | [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | | [deploy\_mock\_clients](#input\_deploy\_mock\_clients) | 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 | -| [enable\_xray\_tracing](#input\_enable\_xray\_tracing) | Enable AWS X-Ray active tracing for Lambda functions | `bool` | `false` | no | +| [enable\_xray\_tracing](#input\_enable\_xray\_tracing) | Enable AWS X-Ray active tracing for Lambda functions | `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 | @@ -30,6 +30,12 @@ | [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 | +| [mtls\_cert\_secret\_arn](#input\_mtls\_cert\_secret\_arn) | Secrets Manager ARN for the shared mTLS client certificate (production) | `string` | `""` | no | +| [mtls\_mock\_server\_cert\_s3\_key](#input\_mtls\_mock\_server\_cert\_s3\_key) | S3 key for the mock webhook server certificate PEM (signed by the test CA) | `string` | `""` | no | +| [mtls\_mock\_server\_key\_s3\_key](#input\_mtls\_mock\_server\_key\_s3\_key) | S3 key for the mock webhook server private key PEM | `string` | `""` | no | +| [mtls\_test\_ca\_s3\_key](#input\_mtls\_test\_ca\_s3\_key) | S3 key for the test CA certificate PEM bundle used for server verification and the mock webhook server cert chain | `string` | `""` | no | +| [mtls\_test\_cert\_s3\_key](#input\_mtls\_test\_cert\_s3\_key) | S3 key for the test mTLS client certificate bundle (non-production) | `string` | `""` | no | +| [mtls\_test\_certs\_s3\_bucket](#input\_mtls\_test\_certs\_s3\_bucket) | S3 bucket containing test mTLS certificate material (non-production) | `string` | `""` | no | | [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 | @@ -45,7 +51,7 @@ | Name | Source | Version | |------|--------|---------| | [client\_config\_bucket](#module\_client\_config\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-s3bucket.zip | n/a | -| [client\_destination](#module\_client\_destination) | ../../modules/client-destination | n/a | +| [client\_delivery](#module\_client\_delivery) | ../../modules/client-delivery | n/a | | [client\_transform\_filter\_lambda](#module\_client\_transform\_filter\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip | n/a | | [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-kms.zip | n/a | | [mock\_webhook\_lambda](#module\_mock\_webhook\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip | n/a | diff --git a/infrastructure/terraform/components/callbacks/cloudwatch_eventbus_main.tf b/infrastructure/terraform/components/callbacks/cloudwatch_eventbus_main.tf index f0ce95c4..2a6e687f 100644 --- a/infrastructure/terraform/components/callbacks/cloudwatch_eventbus_main.tf +++ b/infrastructure/terraform/components/callbacks/cloudwatch_eventbus_main.tf @@ -2,3 +2,9 @@ resource "aws_cloudwatch_event_bus" "main" { name = local.csi kms_key_identifier = module.kms.key_arn } + +resource "aws_cloudwatch_event_archive" "main" { + name = "${local.csi}-archive" + event_source_arn = aws_cloudwatch_event_bus.main.arn + retention_days = 7 +} diff --git a/infrastructure/terraform/components/callbacks/cloudwatch_metric_alarm_dlq_depth.tf b/infrastructure/terraform/components/callbacks/cloudwatch_metric_alarm_dlq_depth.tf deleted file mode 100644 index e6ed2d9d..00000000 --- a/infrastructure/terraform/components/callbacks/cloudwatch_metric_alarm_dlq_depth.tf +++ /dev/null @@ -1,31 +0,0 @@ -resource "aws_cloudwatch_metric_alarm" "client_dlq_depth" { - for_each = toset(keys(local.config_targets)) - - alarm_name = "${local.csi}-${each.key}-dlq-depth" - alarm_description = join(" ", [ - "RELIABILITY: Messages are in DLQ for ${each.key}.", - "Failed callback deliveries require operator attention.", - ]) - - comparison_operator = "GreaterThanThreshold" - evaluation_periods = 1 - metric_name = "ApproximateNumberOfMessagesVisible" - namespace = "AWS/SQS" - period = 300 - statistic = "Sum" - threshold = 0 - actions_enabled = true - treat_missing_data = "notBreaching" - - dimensions = { - QueueName = "${local.csi}-${each.key}-dlq-queue" - } - - tags = merge( - local.default_tags, - { - Name = "${local.csi}-${each.key}-dlq-depth" - Client = local.config_targets[each.key].client_id - }, - ) -} diff --git a/infrastructure/terraform/components/callbacks/elasticache_delivery_state.tf b/infrastructure/terraform/components/callbacks/elasticache_delivery_state.tf new file mode 100644 index 00000000..38d7d7fd --- /dev/null +++ b/infrastructure/terraform/components/callbacks/elasticache_delivery_state.tf @@ -0,0 +1,178 @@ +resource "aws_elasticache_serverless_cache" "delivery_state" { + name = "${local.csi}-delivery-state" + engine = "redis" + major_engine_version = "7" + description = "Per-target rate limiting and circuit breaker state for callback delivery" + + snapshot_retention_limit = 0 + + security_group_ids = [aws_security_group.elasticache_delivery_state.id] + subnet_ids = local.acct.private_subnet_ids + + kms_key_id = module.kms.key_arn + + cache_usage_limits { + data_storage { + maximum = 1 + unit = "GB" + } + + ecpu_per_second { + maximum = 1000 + } + } + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-delivery-state" + Description = "Callback delivery rate limiter and circuit breaker state" + }, + ) +} + +resource "aws_security_group" "elasticache_delivery_state" { + name = "${local.csi}-elasticache-delivery-state" + description = "Security group for ElastiCache delivery state cluster" + vpc_id = local.acct.vpc_id + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-elasticache-delivery-state" + }, + ) +} + +resource "aws_vpc_security_group_ingress_rule" "elasticache_from_lambda" { + security_group_id = aws_security_group.elasticache_delivery_state.id + referenced_security_group_id = aws_security_group.https_client_lambda.id + from_port = 6379 + to_port = 6379 + ip_protocol = "tcp" + description = "Allow HTTPS Client Lambda to connect to ElastiCache" + + tags = local.default_tags +} + +resource "aws_security_group" "https_client_lambda" { + name = "${local.csi}-https-client-lambda" + description = "Security group for per-client HTTPS Client Lambda functions" + vpc_id = local.acct.vpc_id + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-https-client-lambda" + }, + ) +} + +resource "aws_vpc_security_group_egress_rule" "lambda_to_elasticache" { + security_group_id = aws_security_group.https_client_lambda.id + referenced_security_group_id = aws_security_group.elasticache_delivery_state.id + from_port = 6379 + to_port = 6379 + ip_protocol = "tcp" + description = "Allow Lambda to connect to ElastiCache" + + tags = local.default_tags +} + +resource "aws_vpc_security_group_egress_rule" "lambda_to_https" { + security_group_id = aws_security_group.https_client_lambda.id + cidr_ipv4 = "0.0.0.0/0" + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + description = "Allow Lambda outbound HTTPS for webhook delivery" + + tags = local.default_tags +} + +resource "aws_cloudwatch_metric_alarm" "elasticache_ecpu_utilisation" { + alarm_name = "${local.csi}-elasticache-ecpu-utilisation" + alarm_description = join(" ", [ + "PERFORMANCE: ElastiCache processing units utilisation is high.", + "Consider scaling up or optimising Redis commands.", + ]) + + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "ElastiCacheProcessingUnits" + namespace = "AWS/ElastiCache" + period = 300 + statistic = "Average" + threshold = 80 + actions_enabled = true + treat_missing_data = "notBreaching" + + dimensions = { + CacheClusterId = aws_elasticache_serverless_cache.delivery_state.name + } + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-elasticache-ecpu-utilisation" + }, + ) +} + +resource "aws_cloudwatch_metric_alarm" "elasticache_connections" { + alarm_name = "${local.csi}-elasticache-connections" + alarm_description = join(" ", [ + "RELIABILITY: ElastiCache connection count is high.", + "Review per-client Lambda connection pool sizing.", + ]) + + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CurrConnections" + namespace = "AWS/ElastiCache" + period = 300 + statistic = "Maximum" + threshold = 500 + actions_enabled = true + treat_missing_data = "notBreaching" + + dimensions = { + CacheClusterId = aws_elasticache_serverless_cache.delivery_state.name + } + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-elasticache-connections" + }, + ) +} + +resource "aws_cloudwatch_metric_alarm" "elasticache_throttled_ops" { + alarm_name = "${local.csi}-elasticache-throttled-ops" + alarm_description = join(" ", [ + "PERFORMANCE: ElastiCache throttled operations detected.", + "Increase ECPU limit or reduce request rate.", + ]) + + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "ThrottledCmds" + namespace = "AWS/ElastiCache" + period = 300 + statistic = "Sum" + threshold = 0 + actions_enabled = true + treat_missing_data = "notBreaching" + + dimensions = { + CacheClusterId = aws_elasticache_serverless_cache.delivery_state.name + } + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-elasticache-throttled-ops" + }, + ) +} diff --git a/infrastructure/terraform/components/callbacks/locals.tf b/infrastructure/terraform/components/callbacks/locals.tf index f4707154..6c24caa6 100644 --- a/infrastructure/terraform/components/callbacks/locals.tf +++ b/infrastructure/terraform/components/callbacks/locals.tf @@ -20,7 +20,7 @@ locals { targets = [ for target in try(client.targets, []) : merge(target, { - invocationEndpoint = "${aws_lambda_function_url.mock_webhook[0].function_url}${target.targetId}" + invocationEndpoint = try(target.mtls.enabled, false) ? "https://${aws_lb.mock_webhook_mtls[0].dns_name}/${target.targetId}" : "${aws_lambda_function_url.mock_webhook[0].function_url}${target.targetId}" apiKey = merge(target.apiKey, { headerValue = random_password.mock_webhook_api_key[0].result }) }) ] @@ -28,39 +28,29 @@ locals { } : local.config_clients - config_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 = var.deploy_mock_clients ? "${aws_lambda_function_url.mock_webhook[0].function_url}${target.targetId}" : target.invocationEndpoint - invocation_rate_limit_per_second = target.invocationRateLimit - http_method = target.invocationMethod - header_name = target.apiKey.headerName - header_value = var.deploy_mock_clients ? random_password.mock_webhook_api_key[0].result : target.apiKey.headerValue - } - } - ]...) - - config_subscriptions = merge([ - for client_id, data in local.config_clients : { - for subscription in try(data.subscriptions, []) : subscription.subscriptionId => { - client_id = client_id + client_subscriptions = { + for client_id, data in local.config_clients : + client_id => { + for subscription in try(data.subscriptions, []) : + subscription.subscriptionId => { subscription_id = subscription.subscriptionId target_ids = try(subscription.targetIds, []) } } - ]...) - - 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 + } + + client_subscription_targets = { + for client_id, data in local.config_clients : + client_id => merge([ + for subscription in try(data.subscriptions, []) : { + for target_id in try(subscription.targetIds, []) : + "${subscription.subscriptionId}-${target_id}" => { + subscription_id = subscription.subscriptionId + 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_delivery.tf b/infrastructure/terraform/components/callbacks/module_client_delivery.tf new file mode 100644 index 00000000..4e51c4de --- /dev/null +++ b/infrastructure/terraform/components/callbacks/module_client_delivery.tf @@ -0,0 +1,47 @@ +module "client_delivery" { + source = "../../modules/client-delivery" + for_each = local.config_clients + + project = var.project + aws_account_id = var.aws_account_id + region = var.region + component = var.component + environment = var.environment + group = var.group + + client_id = each.key + client_bus_name = aws_cloudwatch_event_bus.main.name + kms_key_arn = module.kms.key_arn + + subscriptions = local.client_subscriptions[each.key] + subscription_targets = local.client_subscription_targets[each.key] + + client_config_bucket = module.client_config_bucket.bucket + client_config_bucket_arn = module.client_config_bucket.arn + + applications_map_parameter_name = local.applications_map_parameter_name + + lambda_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] + lambda_code_base_path = local.aws_lambda_functions_dir_path + + force_lambda_code_deploy = var.force_lambda_code_deploy + log_level = var.log_level + log_retention_in_days = var.log_retention_in_days + enable_xray_tracing = var.enable_xray_tracing + + log_destination_arn = local.log_destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + elasticache_endpoint = aws_elasticache_serverless_cache.delivery_state.endpoint[0].address + elasticache_cache_name = aws_elasticache_serverless_cache.delivery_state.name + elasticache_iam_username = "${var.project}-${var.environment}-${var.component}-elasticache-user" + + mtls_cert_secret_arn = var.mtls_cert_secret_arn + mtls_test_cert_s3_bucket = var.mtls_test_certs_s3_bucket + mtls_test_cert_s3_key = var.mtls_test_cert_s3_key + + vpc_subnet_ids = local.acct.private_subnet_ids + lambda_security_group_id = aws_security_group.https_client_lambda.id + + deploy_mock_clients = var.deploy_mock_clients +} diff --git a/infrastructure/terraform/components/callbacks/module_client_destination.tf b/infrastructure/terraform/components/callbacks/module_client_destination.tf deleted file mode 100644 index 21800e94..00000000 --- a/infrastructure/terraform/components/callbacks/module_client_destination.tf +++ /dev/null @@ -1,17 +0,0 @@ -module "client_destination" { - source = "../../modules/client-destination" - - project = var.project - aws_account_id = var.aws_account_id - region = var.region - component = var.component - environment = var.environment - client_bus_name = aws_cloudwatch_event_bus.main.name - - kms_key_arn = module.kms.key_arn - - targets = local.config_targets - subscriptions = local.config_subscriptions - subscription_targets = local.subscription_targets - -} diff --git a/infrastructure/terraform/components/callbacks/module_mock_webhook_alb_mtls.tf b/infrastructure/terraform/components/callbacks/module_mock_webhook_alb_mtls.tf new file mode 100644 index 00000000..0fdba451 --- /dev/null +++ b/infrastructure/terraform/components/callbacks/module_mock_webhook_alb_mtls.tf @@ -0,0 +1,111 @@ +resource "aws_security_group" "mock_webhook_alb" { + count = var.deploy_mock_clients ? 1 : 0 + name = "${local.csi}-mock-webhook-alb" + description = "Security group for mock webhook ALB mTLS endpoint" + vpc_id = local.acct.vpc_id + + tags = merge( + local.default_tags, + { + Name = "${local.csi}-mock-webhook-alb" + }, + ) +} + +resource "aws_vpc_security_group_ingress_rule" "mock_webhook_alb_https" { + count = var.deploy_mock_clients ? 1 : 0 + security_group_id = aws_security_group.mock_webhook_alb[0].id + referenced_security_group_id = aws_security_group.https_client_lambda.id + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + description = "Allow HTTPS Client Lambda to reach mock webhook via mTLS" + tags = local.default_tags +} + +resource "aws_vpc_security_group_egress_rule" "mock_webhook_alb_egress" { + count = var.deploy_mock_clients ? 1 : 0 + security_group_id = aws_security_group.mock_webhook_alb[0].id + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + tags = local.default_tags +} + +data "aws_s3_object" "mtls_mock_server_cert" { + count = var.deploy_mock_clients ? 1 : 0 + bucket = var.mtls_test_certs_s3_bucket + key = var.mtls_mock_server_cert_s3_key +} + +data "aws_s3_object" "mtls_mock_server_key" { + count = var.deploy_mock_clients ? 1 : 0 + bucket = var.mtls_test_certs_s3_bucket + key = var.mtls_mock_server_key_s3_key +} + +data "aws_s3_object" "mtls_ca_bundle" { + count = var.deploy_mock_clients ? 1 : 0 + bucket = var.mtls_test_certs_s3_bucket + key = var.mtls_test_ca_s3_key +} + +resource "aws_acm_certificate_import" "mock_webhook_server" { + count = var.deploy_mock_clients ? 1 : 0 + certificate = data.aws_s3_object.mtls_mock_server_cert[0].body + private_key = data.aws_s3_object.mtls_mock_server_key[0].body + certificate_chain = data.aws_s3_object.mtls_ca_bundle[0].body + tags = local.default_tags +} + +resource "aws_lb" "mock_webhook_mtls" { + count = var.deploy_mock_clients ? 1 : 0 + name = substr("${local.csi}-mock-mtls", 0, 32) + internal = true + load_balancer_type = "application" + security_groups = [aws_security_group.mock_webhook_alb[0].id] + subnets = local.acct.private_subnet_ids + tags = local.default_tags +} + +resource "aws_lb_target_group" "mock_webhook_mtls" { + count = var.deploy_mock_clients ? 1 : 0 + name = substr("${local.csi}-mock-mtls", 0, 32) + target_type = "lambda" + tags = local.default_tags +} + +resource "aws_lambda_permission" "mock_webhook_mtls_alb" { + count = var.deploy_mock_clients ? 1 : 0 + statement_id = "AllowMtlsAlb" + action = "lambda:InvokeFunction" + function_name = module.mock_webhook_lambda[0].function_name + principal = "elasticloadbalancing.amazonaws.com" + source_arn = aws_lb_target_group.mock_webhook_mtls[0].arn +} + +resource "aws_lb_target_group_attachment" "mock_webhook_mtls" { + count = var.deploy_mock_clients ? 1 : 0 + target_group_arn = aws_lb_target_group.mock_webhook_mtls[0].arn + target_id = module.mock_webhook_lambda[0].function_arn + depends_on = [aws_lambda_permission.mock_webhook_mtls_alb] +} + +resource "aws_lb_listener" "mock_webhook_mtls" { + count = var.deploy_mock_clients ? 1 : 0 + load_balancer_arn = aws_lb.mock_webhook_mtls[0].arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = aws_acm_certificate_import.mock_webhook_server[0].arn + + mutual_authentication { + mode = "passthrough" + } + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.mock_webhook_mtls[0].arn + } + + tags = local.default_tags +} diff --git a/infrastructure/terraform/components/callbacks/pipes_pipe_main.tf b/infrastructure/terraform/components/callbacks/pipes_pipe_main.tf index 3fddfcca..ae914f4f 100644 --- a/infrastructure/terraform/components/callbacks/pipes_pipe_main.tf +++ b/infrastructure/terraform/components/callbacks/pipes_pipe_main.tf @@ -26,8 +26,7 @@ resource "aws_pipes_pipe" "main" { input_template = <, - "subscriptions": <$.subscriptions>, - "signatures": <$.signatures> + "subscriptions": <$.subscriptions> } EOF } diff --git a/infrastructure/terraform/components/callbacks/variables.tf b/infrastructure/terraform/components/callbacks/variables.tf index 74a72d24..673e5874 100644 --- a/infrastructure/terraform/components/callbacks/variables.tf +++ b/infrastructure/terraform/components/callbacks/variables.tf @@ -158,7 +158,7 @@ variable "deploy_mock_clients" { variable "enable_xray_tracing" { type = bool description = "Enable AWS X-Ray active tracing for Lambda functions" - default = false + default = true } variable "message_root_uri" { @@ -177,3 +177,39 @@ variable "s3_enable_force_destroy" { description = "Whether to enable force destroy for the S3 buckets created in this module" default = false } + +variable "mtls_cert_secret_arn" { + type = string + description = "Secrets Manager ARN for the shared mTLS client certificate (production)" + default = "" +} + +variable "mtls_test_certs_s3_bucket" { + type = string + description = "S3 bucket containing test mTLS certificate material (non-production)" + default = "" +} + +variable "mtls_test_cert_s3_key" { + type = string + description = "S3 key for the test mTLS client certificate bundle (non-production)" + default = "" +} + +variable "mtls_test_ca_s3_key" { + type = string + description = "S3 key for the test CA certificate PEM bundle used for server verification and the mock webhook server cert chain" + default = "" +} + +variable "mtls_mock_server_cert_s3_key" { + type = string + description = "S3 key for the mock webhook server certificate PEM (signed by the test CA)" + default = "" +} + +variable "mtls_mock_server_key_s3_key" { + type = string + description = "S3 key for the mock webhook server private key PEM" + default = "" +} diff --git a/infrastructure/terraform/modules/client-delivery/README.md b/infrastructure/terraform/modules/client-delivery/README.md new file mode 100644 index 00000000..caf34063 --- /dev/null +++ b/infrastructure/terraform/modules/client-delivery/README.md @@ -0,0 +1,69 @@ + + + + +## Requirements + +No requirements. +## 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 | `string` | n/a | yes | +| [aws\_account\_id](#input\_aws\_account\_id) | Account ID | `string` | n/a | yes | +| [client\_bus\_name](#input\_client\_bus\_name) | EventBridge bus name for subscription rules | `string` | n/a | yes | +| [client\_config\_bucket](#input\_client\_config\_bucket) | S3 bucket name containing client subscription configuration | `string` | n/a | yes | +| [client\_config\_bucket\_arn](#input\_client\_config\_bucket\_arn) | S3 bucket ARN containing client subscription configuration | `string` | n/a | yes | +| [client\_id](#input\_client\_id) | Unique identifier for this client | `string` | n/a | yes | +| [component](#input\_component) | Component name | `string` | n/a | yes | +| [deploy\_mock\_clients](#input\_deploy\_mock\_clients) | Whether mock client infrastructure is deployed (non-prod only) | `bool` | `false` | no | +| [elasticache\_cache\_name](#input\_elasticache\_cache\_name) | ElastiCache cache name for SigV4 token presigning | `string` | `""` | no | +| [elasticache\_endpoint](#input\_elasticache\_endpoint) | ElastiCache Serverless endpoint URL | `string` | `""` | no | +| [elasticache\_iam\_username](#input\_elasticache\_iam\_username) | IAM username for ElastiCache authentication | `string` | `""` | no | +| [enable\_xray\_tracing](#input\_enable\_xray\_tracing) | Enable AWS X-Ray active tracing for the Lambda function | `bool` | `true` | no | +| [environment](#input\_environment) | The name of the tfscaffold environment | `string` | n/a | yes | +| [force\_lambda\_code\_deploy](#input\_force\_lambda\_code\_deploy) | Force Lambda code redeployment even when commit tag matches | `bool` | `false` | no | +| [group](#input\_group) | The name of the tfscaffold group | `string` | `null` | no | +| [kms\_key\_arn](#input\_kms\_key\_arn) | KMS Key ARN for encryption at rest | `string` | n/a | yes | +| [lambda\_batch\_size](#input\_lambda\_batch\_size) | Number of SQS messages per Lambda invocation | `number` | `10` | no | +| [lambda\_code\_base\_path](#input\_lambda\_code\_base\_path) | Base path to Lambda source code directories | `string` | n/a | yes | +| [lambda\_memory](#input\_lambda\_memory) | Lambda memory allocation in MB | `number` | `256` | no | +| [lambda\_s3\_bucket](#input\_lambda\_s3\_bucket) | S3 bucket for Lambda function artefacts | `string` | n/a | yes | +| [lambda\_security\_group\_id](#input\_lambda\_security\_group\_id) | Security group ID for the Lambda function | `string` | `""` | no | +| [lambda\_timeout](#input\_lambda\_timeout) | Lambda timeout in seconds | `number` | `30` | no | +| [log\_destination\_arn](#input\_log\_destination\_arn) | Firehose destination ARN for log forwarding | `string` | `""` | no | +| [log\_level](#input\_log\_level) | Log level for the Lambda function | `string` | `"INFO"` | no | +| [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | CloudWatch log retention period in days | `number` | `0` | no | +| [log\_subscription\_role\_arn](#input\_log\_subscription\_role\_arn) | IAM role ARN for CloudWatch log subscription | `string` | `""` | no | +| [max\_retry\_duration\_seconds](#input\_max\_retry\_duration\_seconds) | Maximum retry window before messages are sent to DLQ | `number` | `7200` | no | +| [mtls\_cert\_secret\_arn](#input\_mtls\_cert\_secret\_arn) | Secrets Manager ARN for the mTLS client certificate (production) | `string` | `""` | no | +| [mtls\_test\_cert\_s3\_bucket](#input\_mtls\_test\_cert\_s3\_bucket) | S3 bucket for non-production mTLS test certificates | `string` | `""` | no | +| [mtls\_test\_cert\_s3\_key](#input\_mtls\_test\_cert\_s3\_key) | S3 key for non-production mTLS test certificate bundle | `string` | `""` | no | +| [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | +| [region](#input\_region) | AWS Region | `string` | n/a | yes | +| [sqs\_max\_receive\_count](#input\_sqs\_max\_receive\_count) | Maximum receive count before message moves to DLQ | `number` | `100` | no | +| [sqs\_visibility\_timeout\_seconds](#input\_sqs\_visibility\_timeout\_seconds) | Visibility timeout for the per-client delivery queue | `number` | `60` | no | +| [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) | Subscription definitions for this client, keyed by subscription\_id |
map(object({
subscription_id = string
target_ids = list(string)
}))
| n/a | yes | +| [vpc\_subnet\_ids](#input\_vpc\_subnet\_ids) | VPC subnet IDs for Lambda execution | `list(string)` | `[]` | no | +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [dlq\_delivery](#module\_dlq\_delivery) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-sqs.zip | n/a | +| [https\_client\_lambda](#module\_https\_client\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip | n/a | +| [sqs\_delivery](#module\_sqs\_delivery) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-sqs.zip | n/a | +## Outputs + +| Name | Description | +|------|-------------| +| [delivery\_queue\_arn](#output\_delivery\_queue\_arn) | ARN of the per-client delivery SQS queue | +| [delivery\_queue\_url](#output\_delivery\_queue\_url) | URL of the per-client delivery SQS queue | +| [dlq\_arn](#output\_dlq\_arn) | ARN of the per-client delivery DLQ | +| [dlq\_url](#output\_dlq\_url) | URL of the per-client delivery DLQ | +| [lambda\_execution\_role\_arn](#output\_lambda\_execution\_role\_arn) | ARN of the Lambda execution IAM role | +| [lambda\_function\_arn](#output\_lambda\_function\_arn) | ARN of the per-client HTTPS Client Lambda function | +| [lambda\_function\_name](#output\_lambda\_function\_name) | Name of the per-client HTTPS Client Lambda function | + + + diff --git a/infrastructure/terraform/modules/client-delivery/cloudwatch_event_rule_per_subscription.tf b/infrastructure/terraform/modules/client-delivery/cloudwatch_event_rule_per_subscription.tf new file mode 100644 index 00000000..de523ccd --- /dev/null +++ b/infrastructure/terraform/modules/client-delivery/cloudwatch_event_rule_per_subscription.tf @@ -0,0 +1,101 @@ +resource "aws_cloudwatch_event_rule" "per_subscription" { + for_each = var.subscriptions + + name = "${local.client_prefix}-${each.key}" + description = "Client Callbacks event rule for client ${var.client_id} subscription ${each.key}" + event_bus_name = var.client_bus_name + + event_pattern = jsonencode({ + "detail" : { + "subscriptions" : [each.value.subscription_id] + } + }) + + tags = local.default_tags +} + +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 = module.sqs_delivery.sqs_queue_arn + target_id = "${local.client_prefix}-${each.value.target_id}" + event_bus_name = var.client_bus_name + + sqs_target { + message_group_id = null + } + + input_transformer { + input_paths = { + payload = "$.detail.payload" + subscriptions = "$.detail.subscriptions" + } + + input_template = "{\"payload\": , \"subscriptions\": , \"targetId\": \"${each.value.target_id}\"}" + } + + dead_letter_config { + arn = module.dlq_delivery.sqs_queue_arn + } + + retry_policy { + maximum_retry_attempts = 0 + maximum_event_age_in_seconds = 60 + } +} + +resource "aws_iam_role" "eventbridge_sqs_target" { + name = "${local.client_prefix}-eb-sqs-role" + description = "Role for EventBridge to send messages to per-client SQS queue" + assume_role_policy = data.aws_iam_policy_document.eventbridge_sqs_assume.json + + tags = local.default_tags +} + +data "aws_iam_policy_document" "eventbridge_sqs_assume" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["events.amazonaws.com"] + } + } +} + +resource "aws_iam_role_policy" "eventbridge_sqs_send" { + name = "sqs-send" + role = aws_iam_role.eventbridge_sqs_target.id + policy = data.aws_iam_policy_document.eventbridge_sqs_send.json +} + +data "aws_iam_policy_document" "eventbridge_sqs_send" { + statement { + sid = "AllowSQSSendMessage" + effect = "Allow" + + actions = [ + "sqs:SendMessage", + ] + + resources = [ + module.sqs_delivery.sqs_queue_arn, + module.dlq_delivery.sqs_queue_arn, + ] + } + + statement { + sid = "AllowKMSForSQS" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [ + var.kms_key_arn, + ] + } +} diff --git a/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf b/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf new file mode 100644 index 00000000..9d2348a0 --- /dev/null +++ b/infrastructure/terraform/modules/client-delivery/iam_role_sqs_target.tf @@ -0,0 +1,115 @@ +data "aws_iam_policy_document" "https_client_lambda" { + statement { + sid = "KMSPermissions" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [ + var.kms_key_arn, + ] + } + + statement { + sid = "SQSDeliveryQueueConsume" + effect = "Allow" + + actions = [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:ChangeMessageVisibility", + ] + + resources = [ + module.sqs_delivery.sqs_queue_arn, + ] + } + + statement { + sid = "SQSDLQSend" + effect = "Allow" + + actions = [ + "sqs:SendMessage", + ] + + resources = [ + module.dlq_delivery.sqs_queue_arn, + ] + } + + statement { + sid = "SSMGetApplicationsMap" + effect = "Allow" + + actions = [ + "ssm:GetParameter", + ] + + resources = [ + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter${var.applications_map_parameter_name}", + ] + } + + statement { + sid = "S3ClientConfigReadAccess" + effect = "Allow" + + actions = [ + "s3:GetObject", + ] + + resources = [ + "${var.client_config_bucket_arn}/client_subscriptions/*", + ] + } + + statement { + sid = "S3ClientConfigListAccess" + effect = "Allow" + + actions = [ + "s3:ListBucket", + ] + + resources = [ + var.client_config_bucket_arn, + ] + } + + dynamic "statement" { + for_each = var.mtls_cert_secret_arn != "" ? [1] : [] + content { + sid = "SecretsManagerMTLSCert" + effect = "Allow" + + actions = [ + "secretsmanager:GetSecretValue", + ] + + resources = [ + var.mtls_cert_secret_arn, + ] + } + } + + dynamic "statement" { + for_each = var.mtls_test_cert_s3_bucket != "" ? [1] : [] + content { + sid = "S3MTLSTestCertReadAccess" + effect = "Allow" + + actions = [ + "s3:GetObject", + ] + + resources = [ + "arn:aws:s3:::${var.mtls_test_cert_s3_bucket}/${var.mtls_test_cert_s3_key}", + ] + } + } +} diff --git a/infrastructure/terraform/modules/client-delivery/locals.tf b/infrastructure/terraform/modules/client-delivery/locals.tf new file mode 100644 index 00000000..6ca35137 --- /dev/null +++ b/infrastructure/terraform/modules/client-delivery/locals.tf @@ -0,0 +1,21 @@ +locals { + csi = replace( + format( + "%s-%s-%s", + var.project, + var.environment, + var.component, + ), + "_", + "", + ) + + client_prefix = "${local.csi}-${var.client_id}" + + default_tags = { + Project = var.project + Environment = var.environment + Component = var.component + Client = var.client_id + } +} diff --git a/infrastructure/terraform/modules/client-delivery/module_dlq_per_client.tf b/infrastructure/terraform/modules/client-delivery/module_dlq_per_client.tf new file mode 100644 index 00000000..84c410dd --- /dev/null +++ b/infrastructure/terraform/modules/client-delivery/module_dlq_per_client.tf @@ -0,0 +1,43 @@ +module "dlq_delivery" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-sqs.zip" + + aws_account_id = var.aws_account_id + component = var.component + environment = var.environment + project = var.project + region = var.region + name = "${var.client_id}-delivery-dlq" + + sqs_kms_key_arn = var.kms_key_arn + + create_dlq = false +} + +resource "aws_cloudwatch_metric_alarm" "dlq_depth" { + alarm_name = "${local.client_prefix}-dlq-depth" + alarm_description = join(" ", [ + "RELIABILITY: Messages are in DLQ for client ${var.client_id}.", + "Failed callback deliveries require operator attention.", + ]) + + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "ApproximateNumberOfMessagesVisible" + namespace = "AWS/SQS" + period = 300 + statistic = "Sum" + threshold = 0 + actions_enabled = true + treat_missing_data = "notBreaching" + + dimensions = { + QueueName = "${local.client_prefix}-delivery-dlq-queue" + } + + tags = merge( + local.default_tags, + { + Name = "${local.client_prefix}-dlq-depth" + }, + ) +} diff --git a/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf b/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf new file mode 100644 index 00000000..60a540a2 --- /dev/null +++ b/infrastructure/terraform/modules/client-delivery/module_https_client_lambda.tf @@ -0,0 +1,70 @@ +module "https_client_lambda" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-lambda.zip" + + function_name = "https-client-${var.client_id}" + description = "HTTPS delivery Lambda for client ${var.client_id}" + + aws_account_id = var.aws_account_id + component = var.component + environment = var.environment + project = var.project + region = var.region + group = var.group + + log_retention_in_days = var.log_retention_in_days + kms_key_arn = var.kms_key_arn + + iam_policy_document = { + body = data.aws_iam_policy_document.https_client_lambda.json + } + + function_s3_bucket = var.lambda_s3_bucket + function_code_base_path = var.lambda_code_base_path + function_code_dir = "https-client-lambda/dist" + function_include_common = true + handler_function_name = "handler" + runtime = "nodejs22.x" + memory = var.lambda_memory + timeout = var.lambda_timeout + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + enable_xray_tracing = var.enable_xray_tracing + + log_destination_arn = var.log_destination_arn + log_subscription_role_arn = var.log_subscription_role_arn + + lambda_env_vars = { + ENVIRONMENT = var.environment + CLIENT_ID = var.client_id + CLIENT_SUBSCRIPTION_CONFIG_BUCKET = var.client_config_bucket + CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/" + CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "60" + APPLICATIONS_MAP_PARAMETER = var.applications_map_parameter_name + QUEUE_URL = module.sqs_delivery.sqs_queue_url + DLQ_URL = module.dlq_delivery.sqs_queue_url + METRICS_NAMESPACE = "nhs-notify-client-callbacks" + MAX_RETRY_DURATION_SECONDS = tostring(var.max_retry_duration_seconds) + MTLS_CERT_SECRET_ARN = var.mtls_cert_secret_arn + MTLS_TEST_CERT_S3_BUCKET = var.mtls_test_cert_s3_bucket + MTLS_TEST_CERT_S3_KEY = var.mtls_test_cert_s3_key + ELASTICACHE_ENDPOINT = var.elasticache_endpoint + ELASTICACHE_CACHE_NAME = var.elasticache_cache_name + ELASTICACHE_IAM_USERNAME = var.elasticache_iam_username + } + + vpc_config = var.lambda_security_group_id != "" ? { + subnet_ids = var.vpc_subnet_ids + security_group_ids = [var.lambda_security_group_id] + } : null +} + +resource "aws_lambda_event_source_mapping" "sqs_delivery" { + event_source_arn = module.sqs_delivery.sqs_queue_arn + function_name = module.https_client_lambda.function_arn + batch_size = var.lambda_batch_size + enabled = true + + function_response_types = ["ReportBatchItemFailures"] +} diff --git a/infrastructure/terraform/modules/client-delivery/module_sqs_per_client.tf b/infrastructure/terraform/modules/client-delivery/module_sqs_per_client.tf new file mode 100644 index 00000000..cb6cf16a --- /dev/null +++ b/infrastructure/terraform/modules/client-delivery/module_sqs_per_client.tf @@ -0,0 +1,39 @@ +module "sqs_delivery" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-sqs.zip" + + aws_account_id = var.aws_account_id + component = var.component + environment = var.environment + project = var.project + region = var.region + name = "${var.client_id}-delivery" + + sqs_kms_key_arn = var.kms_key_arn + + visibility_timeout_seconds = var.sqs_visibility_timeout_seconds + max_receive_count = var.sqs_max_receive_count + + create_dlq = false + + sqs_policy_overload = data.aws_iam_policy_document.sqs_delivery.json +} + +data "aws_iam_policy_document" "sqs_delivery" { + statement { + sid = "AllowEventBridgeToSendMessage" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["events.amazonaws.com"] + } + + actions = [ + "sqs:SendMessage", + ] + + resources = [ + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-${var.client_id}-delivery-queue", + ] + } +} diff --git a/infrastructure/terraform/modules/client-delivery/outputs.tf b/infrastructure/terraform/modules/client-delivery/outputs.tf new file mode 100644 index 00000000..727ae19d --- /dev/null +++ b/infrastructure/terraform/modules/client-delivery/outputs.tf @@ -0,0 +1,34 @@ +output "delivery_queue_arn" { + description = "ARN of the per-client delivery SQS queue" + value = module.sqs_delivery.sqs_queue_arn +} + +output "delivery_queue_url" { + description = "URL of the per-client delivery SQS queue" + value = module.sqs_delivery.sqs_queue_url +} + +output "dlq_arn" { + description = "ARN of the per-client delivery DLQ" + value = module.dlq_delivery.sqs_queue_arn +} + +output "dlq_url" { + description = "URL of the per-client delivery DLQ" + value = module.dlq_delivery.sqs_queue_url +} + +output "lambda_function_name" { + description = "Name of the per-client HTTPS Client Lambda function" + value = module.https_client_lambda.function_name +} + +output "lambda_function_arn" { + description = "ARN of the per-client HTTPS Client Lambda function" + value = module.https_client_lambda.function_arn +} + +output "lambda_execution_role_arn" { + description = "ARN of the Lambda execution IAM role" + value = module.https_client_lambda.iam_role_arn +} diff --git a/infrastructure/terraform/modules/client-delivery/variables.tf b/infrastructure/terraform/modules/client-delivery/variables.tf new file mode 100644 index 00000000..7dc3aed7 --- /dev/null +++ b/infrastructure/terraform/modules/client-delivery/variables.tf @@ -0,0 +1,212 @@ +variable "project" { + type = string + description = "The name of the tfscaffold project" +} + +variable "environment" { + type = string + description = "The name of the tfscaffold environment" +} + +variable "component" { + type = string + description = "Component name" +} + +variable "aws_account_id" { + type = string + description = "Account ID" +} + +variable "region" { + type = string + description = "AWS Region" +} + +variable "group" { + type = string + description = "The name of the tfscaffold group" + default = null +} + +variable "client_id" { + type = string + description = "Unique identifier for this client" +} + +variable "kms_key_arn" { + type = string + description = "KMS Key ARN for encryption at rest" +} + +variable "client_bus_name" { + type = string + description = "EventBridge bus name for subscription rules" +} + +variable "subscriptions" { + type = map(object({ + subscription_id = string + target_ids = list(string) + })) + description = "Subscription definitions for this client, keyed by subscription_id" +} + +variable "subscription_targets" { + type = map(object({ + subscription_id = string + target_id = string + })) + description = "Flattened subscription-target fanout map keyed by subscription-target composite key" +} + +variable "client_config_bucket" { + type = string + description = "S3 bucket name containing client subscription configuration" +} + +variable "client_config_bucket_arn" { + type = string + description = "S3 bucket ARN containing client subscription configuration" +} + +variable "applications_map_parameter_name" { + type = string + description = "SSM Parameter Store path for the clientId-to-applicationData map" +} + +variable "lambda_s3_bucket" { + type = string + description = "S3 bucket for Lambda function artefacts" +} + +variable "lambda_code_base_path" { + type = string + description = "Base path to Lambda source code directories" +} + +variable "force_lambda_code_deploy" { + type = bool + description = "Force Lambda code redeployment even when commit tag matches" + default = false +} + +variable "log_level" { + type = string + description = "Log level for the Lambda function" + default = "INFO" +} + +variable "log_retention_in_days" { + type = number + description = "CloudWatch log retention period in days" + default = 0 +} + +variable "log_destination_arn" { + type = string + description = "Firehose destination ARN for log forwarding" + default = "" +} + +variable "log_subscription_role_arn" { + type = string + description = "IAM role ARN for CloudWatch log subscription" + default = "" +} + +variable "lambda_batch_size" { + type = number + description = "Number of SQS messages per Lambda invocation" + default = 10 +} + +variable "lambda_memory" { + type = number + description = "Lambda memory allocation in MB" + default = 256 +} + +variable "lambda_timeout" { + type = number + description = "Lambda timeout in seconds" + default = 30 +} + +variable "max_retry_duration_seconds" { + type = number + description = "Maximum retry window before messages are sent to DLQ" + default = 7200 +} + +variable "sqs_visibility_timeout_seconds" { + type = number + description = "Visibility timeout for the per-client delivery queue" + default = 60 +} + +variable "sqs_max_receive_count" { + type = number + description = "Maximum receive count before message moves to DLQ" + default = 100 +} + +variable "enable_xray_tracing" { + type = bool + description = "Enable AWS X-Ray active tracing for the Lambda function" + default = true +} + +variable "deploy_mock_clients" { + type = bool + description = "Whether mock client infrastructure is deployed (non-prod only)" + default = false +} + +variable "mtls_cert_secret_arn" { + type = string + description = "Secrets Manager ARN for the mTLS client certificate (production)" + default = "" +} + +variable "mtls_test_cert_s3_bucket" { + type = string + description = "S3 bucket for non-production mTLS test certificates" + default = "" +} + +variable "mtls_test_cert_s3_key" { + type = string + description = "S3 key for non-production mTLS test certificate bundle" + default = "" +} + +variable "elasticache_endpoint" { + type = string + description = "ElastiCache Serverless endpoint URL" + default = "" +} + +variable "elasticache_cache_name" { + type = string + description = "ElastiCache cache name for SigV4 token presigning" + default = "" +} + +variable "elasticache_iam_username" { + type = string + description = "IAM username for ElastiCache authentication" + default = "" +} + +variable "vpc_subnet_ids" { + type = list(string) + description = "VPC subnet IDs for Lambda execution" + default = [] +} + +variable "lambda_security_group_id" { + type = string + description = "Security group ID for the Lambda function" + default = "" +} diff --git a/infrastructure/terraform/modules/client-destination/README.md b/infrastructure/terraform/modules/client-destination/README.md deleted file mode 100644 index 11b689c3..00000000 --- a/infrastructure/terraform/modules/client-destination/README.md +++ /dev/null @@ -1,32 +0,0 @@ - - - - -## Requirements - -No requirements. -## Inputs - -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| [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 | -| [component](#input\_component) | Component name | `string` | n/a | yes | -| [environment](#input\_environment) | The name of the tfscaffold environment | `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 | -|------|--------|---------| -| [target\_dlq](#module\_target\_dlq) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/terraform-sqs.zip | n/a | -## Outputs - -No outputs. - - - 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 deleted file mode 100644 index 4bec92cc..00000000 --- a/infrastructure/terraform/modules/client-destination/cloudwatch_event_api_destination_this.tf +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 7546d666..00000000 --- a/infrastructure/terraform/modules/client-destination/cloudwatch_event_connection_main.tf +++ /dev/null @@ -1,14 +0,0 @@ -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 = 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 deleted file mode 100644 index bdf7ea47..00000000 --- a/infrastructure/terraform/modules/client-destination/cloudwatch_event_rule_main.tf +++ /dev/null @@ -1,46 +0,0 @@ -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" : { - "subscriptions" : [each.value.subscription_id] - } - }) -} - -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 - - dead_letter_config { - arn = module.target_dlq[each.value.target_id].sqs_queue_arn - } - - input_transformer { - input_paths = { - data = "$.detail.payload.data" - } - - input_template = "{\"data\": }" - } - - http_target { - header_parameters = { - "x-hmac-sha256-signature" = "$.detail.signatures.${replace(each.value.target_id, "-", "_")}" - } - } - - retry_policy { - maximum_retry_attempts = 3 - maximum_event_age_in_seconds = 3600 - } -} 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 deleted file mode 100644 index 1158a2b2..00000000 --- a/infrastructure/terraform/modules/client-destination/iam_role_api_target_role.tf +++ /dev/null @@ -1,83 +0,0 @@ -resource "aws_iam_role" "api_target_role" { - name = "${local.csi}-api-target-target-role" - description = "Role for client target rule" - assume_role_policy = data.aws_iam_policy_document.api_target_role_assume_role_policy.json -} - -data "aws_iam_policy_document" "api_target_role_assume_role_policy" { - statement { - actions = [ - "sts:AssumeRole" - ] - - principals { - type = "Service" - identifiers = ["events.amazonaws.com"] - } - } -} - -resource "aws_iam_role_policy_attachment" "api_target_role" { - role = aws_iam_role.api_target_role.id - policy_arn = aws_iam_policy.api_target_role.arn -} - -resource "aws_iam_policy" "api_target_role" { - name = "${local.csi}-api-target-role-policy" - description = "IAM Policy for the client target role" - path = "/" - policy = data.aws_iam_policy_document.api_target_role.json -} - -data "aws_iam_policy_document" "api_target_role" { - dynamic "statement" { - for_each = length(aws_cloudwatch_event_api_destination.per_target) > 0 ? [1] : [] - content { - sid = "AllowAPIDestinationAccess" - effect = "Allow" - - actions = [ - "events:InvokeApiDestination", - ] - - resources = [ - for destination in aws_cloudwatch_event_api_destination.per_target : - destination.arn - ] - } - } - - dynamic "statement" { - for_each = length(module.target_dlq) > 0 ? [1] : [] - content { - sid = "AllowSQSSendMessageForDLQ" - effect = "Allow" - - actions = [ - "sqs:SendMessage", - ] - - resources = [ - for dlq in module.target_dlq : - dlq.sqs_queue_arn - ] - } - } - - statement { - sid = "AllowKMSForDLQ" - effect = "Allow" - - actions = [ - "kms:ReEncrypt*", - "kms:GenerateDataKey*", - "kms:Encrypt", - "kms:DescribeKey", - "kms:Decrypt" - ] - - resources = [ - var.kms_key_arn, - ] - } -} diff --git a/infrastructure/terraform/modules/client-destination/locals.tf b/infrastructure/terraform/modules/client-destination/locals.tf deleted file mode 100644 index fe672990..00000000 --- a/infrastructure/terraform/modules/client-destination/locals.tf +++ /dev/null @@ -1,12 +0,0 @@ -locals { - csi = replace( - format( - "%s-%s-%s", - var.project, - var.environment, - var.component, - ), - "_", - "", - ) -} diff --git a/infrastructure/terraform/modules/client-destination/module_target_dlq.tf b/infrastructure/terraform/modules/client-destination/module_target_dlq.tf deleted file mode 100644 index 36c4c277..00000000 --- a/infrastructure/terraform/modules/client-destination/module_target_dlq.tf +++ /dev/null @@ -1,41 +0,0 @@ -module "target_dlq" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.7/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 = "${each.key}-dlq" - - sqs_kms_key_arn = var.kms_key_arn - - visibility_timeout_seconds = 60 - - create_dlq = false - - 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" - - principals { - type = "Service" - identifiers = ["events.amazonaws.com"] - } - - actions = [ - "sqs:SendMessage" - ] - - resources = [ - "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 deleted file mode 100644 index 2b9a0ceb..00000000 --- a/infrastructure/terraform/modules/client-destination/variables.tf +++ /dev/null @@ -1,67 +0,0 @@ -variable "project" { - type = string - description = "The name of the tfscaffold project" -} - -variable "environment" { - type = string - description = "The name of the tfscaffold environment" -} - -variable "component" { - type = string - description = "Component name" -} - -variable "aws_account_id" { - type = string - description = "Account ID" -} - -variable "region" { - type = string - description = "AWS Region" -} - -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 - })) - - description = "Flattened target definitions keyed by target_id" -} - -variable "subscriptions" { - type = map(object({ - client_id = string - subscription_id = string - target_ids = list(string) - })) - - description = "Flattened subscription definitions keyed by subscription_id" -} - -variable "subscription_targets" { - type = map(object({ - subscription_id = string - target_id = string - })) - - description = "Flattened subscription-target fanout map keyed by subscription-target composite key" -} - -variable "client_bus_name" { - type = string - description = "EventBus name where you create the rule" -} - -variable "kms_key_arn" { - type = string - description = "KMS Key ARN" -} diff --git a/knip.ts b/knip.ts index 3dd626cb..d90e13af 100644 --- a/knip.ts +++ b/knip.ts @@ -32,9 +32,15 @@ const config: KnipConfig = { // Resolved transitively through tsconfig.base.json → @tsconfig/node22 ignoreDependencies: ["@tsconfig/node22"], }, + "lambdas/https-client-lambda": { + ignoreDependencies: ["@tsconfig/node22"], + }, "lambdas/mock-webhook-lambda": { ignoreDependencies: ["@tsconfig/node22"], }, + "src/config-cache": { + ignoreDependencies: ["@tsconfig/node22"], + }, "src/logger": { ignoreDependencies: ["@tsconfig/node22"], }, @@ -42,6 +48,7 @@ const config: KnipConfig = { ignoreDependencies: ["@tsconfig/node22"], }, "tests/integration": { + entry: ["helpers/**/*.ts"], ignoreDependencies: [ "@tsconfig/node22", // Used in helpers/sqs.ts and helpers/cloudwatch.ts; flagged because diff --git a/lambdas/client-transform-filter-lambda/package.json b/lambdas/client-transform-filter-lambda/package.json index 266911da..1e40a5a0 100644 --- a/lambdas/client-transform-filter-lambda/package.json +++ b/lambdas/client-transform-filter-lambda/package.json @@ -2,6 +2,7 @@ "dependencies": { "@aws-sdk/client-s3": "catalog:aws", "@aws-sdk/client-ssm": "catalog:aws", + "@nhs-notify-client-callbacks/config-cache": "workspace:*", "@nhs-notify-client-callbacks/logger": "workspace:*", "@nhs-notify-client-callbacks/models": "workspace:*", "aws-embedded-metrics": "catalog:app", diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/helpers/client-subscription-fixtures.ts b/lambdas/client-transform-filter-lambda/src/__tests__/helpers/client-subscription-fixtures.ts index 9491292c..7713813e 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/helpers/client-subscription-fixtures.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/helpers/client-subscription-fixtures.ts @@ -28,6 +28,8 @@ export const createTarget = ( headerValue: "secret", ...overrides.apiKey, }, + mtls: { enabled: false }, + certPinning: { enabled: false }, ...overrides, }); 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 b46c49f8..95e77ad0 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 @@ -50,12 +50,11 @@ jest.mock("aws-embedded-metrics", () => ({ })); import { GetObjectCommand, NoSuchKey } from "@aws-sdk/client-s3"; -import { GetParameterCommand } from "@aws-sdk/client-ssm"; import type { SQSRecord } from "aws-lambda"; import { EventTypes } from "@nhs-notify-client-callbacks/models"; import { createMessageStatusConfig } from "__tests__/helpers/client-subscription-fixtures"; import { createS3Client } from "services/config-loader-service"; -import { applicationsMapService, configLoaderService, handler } from ".."; +import { configLoaderService, handler } from ".."; const makeSqsRecord = (body: object): SQSRecord => ({ messageId: "sqs-id", @@ -114,7 +113,6 @@ describe("Lambda handler with S3 subscription filtering", () => { beforeEach(() => { mockSend.mockClear(); mockSsmSend.mockClear(); - applicationsMapService.reset(); mockSsmSend.mockResolvedValue({ Parameter: { Value: applicationsMap } }); // Reset loader and clear cache for clean state between tests configLoaderService.reset( @@ -148,12 +146,8 @@ describe("Lambda handler with S3 subscription filtering", () => { expect(result).toHaveLength(1); expect(mockSend).toHaveBeenCalledTimes(1); expect(mockSend.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); - expect(mockSsmSend).toHaveBeenCalledTimes(1); - expect(mockSsmSend.mock.calls[0][0]).toBeInstanceOf(GetParameterCommand); 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 () => { @@ -251,25 +245,4 @@ describe("Lambda handler with S3 subscription filtering", () => { // S3 fetched once per distinct client (client-a and client-b), not once per event expect(mockSend).toHaveBeenCalledTimes(2); }); - - it("filters out event when no applicationId found in SSM map", async () => { - mockSend.mockResolvedValue({ - Body: { - transformToString: jest - .fn() - .mockResolvedValue( - JSON.stringify(createValidConfig("client-unknown")), - ), - }, - }); - mockSsmSend.mockResolvedValue({ - Parameter: { Value: JSON.stringify({}) }, - }); - - const result = await handler([ - makeSqsRecord(validMessageStatusEvent("client-unknown", "DELIVERED")), - ]); - - expect(result).toHaveLength(0); - }); }); 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 14b10096..168d128d 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -10,7 +10,6 @@ import type { import type { Logger } from "services/logger"; import type { CallbackMetrics } from "services/metrics"; import type { ConfigLoader } from "services/config-loader"; -import type { ApplicationsMapService } from "services/ssm-applications-map"; import { ObservabilityService } from "services/observability"; import { ConfigLoaderService } from "services/config-loader-service"; import { @@ -71,15 +70,6 @@ const makeStubConfigLoaderService = (): ConfigLoaderService => { return { getLoader: () => loader } as unknown as ConfigLoaderService; }; -const makeStubApplicationsMapService = (): ApplicationsMapService => - ({ - getApplicationId: jest - .fn() - .mockImplementation( - async (clientId: string) => `test-app-id-${clientId}`, - ), - }) as unknown as ApplicationsMapService; - describe("Lambda handler", () => { const mockLogger = { info: jest.fn(), @@ -109,7 +99,6 @@ describe("Lambda handler", () => { createObservabilityService: () => new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger), createConfigLoaderService: makeStubConfigLoaderService, - createApplicationsMapService: makeStubApplicationsMapService, }); beforeEach(() => { @@ -173,7 +162,6 @@ describe("Lambda handler", () => { expect(result).toHaveLength(1); 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( @@ -203,7 +191,6 @@ describe("Lambda handler", () => { new ObservabilityService(mockLogger, mockMetrics, mockMetricsLogger), createConfigLoaderService: () => ({ getLoader: () => customConfigLoader }) as ConfigLoaderService, - createApplicationsMapService: makeStubApplicationsMapService, }); const sqsMessage: SQSRecord = { @@ -234,65 +221,6 @@ describe("Lambda handler", () => { ); }); - it("should throw when any target is missing an apiKey", 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", - }; - - await expect(handlerWithMixedTargets([sqsMessage])).rejects.toThrow( - "Missing apiKey for target target-no-key", - ); - }); - it("should handle batch of SQS messages from EventBridge Pipes", async () => { const sqsMessages: SQSRecord[] = [ { @@ -414,7 +342,6 @@ describe("Lambda handler", () => { expect(result).toHaveLength(1); 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( @@ -481,7 +408,6 @@ describe("Lambda handler", () => { const faultyHandler = createHandler({ createObservabilityService: () => faultyObservability, createConfigLoaderService: makeStubConfigLoaderService, - createApplicationsMapService: makeStubApplicationsMapService, }); const sqsMessage: SQSRecord = { @@ -662,7 +588,6 @@ describe("createHandler default wiring", () => { [], state.mockObservabilityInstance, expect.any(Object), - expect.any(Object), ); expect(result).toEqual(["ok"]); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts index 6199b92c..e86ef69f 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-cache.test.ts @@ -3,7 +3,7 @@ import { createClientSubscriptionConfig, createMessageStatusSubscription, } from "__tests__/helpers/client-subscription-fixtures"; -import { ConfigCache } from "services/config-cache"; +import { ConfigCache } from "@nhs-notify-client-callbacks/config-cache"; const createConfig = (): ClientSubscriptionConfiguration => createClientSubscriptionConfig("client-1", { diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts index 495164fb..a94a5e0c 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-loader.test.ts @@ -1,6 +1,6 @@ import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3"; import { createMessageStatusConfig } from "__tests__/helpers/client-subscription-fixtures"; -import { ConfigCache } from "services/config-cache"; +import { ConfigCache } from "@nhs-notify-client-callbacks/config-cache"; import { ConfigLoader } from "services/config-loader"; import { ConfigValidationError } from "services/validators/config-validator"; diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts index 81af7f04..c6e0e532 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/config-update.component.test.ts @@ -1,6 +1,6 @@ import { S3Client } from "@aws-sdk/client-s3"; import { createMessageStatusConfig } from "__tests__/helpers/client-subscription-fixtures"; -import { ConfigCache } from "services/config-cache"; +import { ConfigCache } from "@nhs-notify-client-callbacks/config-cache"; import { ConfigLoader } from "services/config-loader"; const makeConfig = (messageStatuses: string[]) => diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/payload-signer.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/payload-signer.test.ts deleted file mode 100644 index e1785d55..00000000 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/payload-signer.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createHmac } from "node:crypto"; -import type { ClientCallbackPayload } from "@nhs-notify-client-callbacks/models"; -import { signPayload } from "services/payload-signer"; - -const makePayload = (id = "msg-1") => - ({ data: [{ id }] }) as unknown as ClientCallbackPayload; - -describe("signPayload", () => { - it("produces the expected HMAC-SHA256 hex string", () => { - const payload = makePayload(); - const applicationId = "app-id-1"; - const apiKey = "api-key-1"; - - const expected = createHmac("sha256", `${applicationId}.${apiKey}`) - .update(JSON.stringify(payload)) - .digest("hex"); - - expect(signPayload(payload, applicationId, apiKey)).toBe(expected); - }); - - it("returns a non-empty hex string", () => { - const result = signPayload(makePayload(), "app-id", "api-key"); - expect(result).toMatch(/^[0-9a-f]+$/); - }); - - it("produces different signatures for different payloads", () => { - const apiKey = "key"; - const appId = "app"; - expect(signPayload(makePayload("msg-1"), appId, apiKey)).not.toBe( - signPayload(makePayload("msg-2"), appId, apiKey), - ); - }); - - it("produces different signatures for different applicationIds", () => { - const payload = makePayload(); - const apiKey = "key"; - expect(signPayload(payload, "app-1", apiKey)).not.toBe( - signPayload(payload, "app-2", apiKey), - ); - }); - - it("produces different signatures for different apiKeys", () => { - const payload = makePayload(); - const appId = "app"; - expect(signPayload(payload, appId, "key-1")).not.toBe( - signPayload(payload, appId, "key-2"), - ); - }); -}); diff --git a/lambdas/client-transform-filter-lambda/src/handler.ts b/lambdas/client-transform-filter-lambda/src/handler.ts index 0d1f20b6..be05991c 100644 --- a/lambdas/client-transform-filter-lambda/src/handler.ts +++ b/lambdas/client-transform-filter-lambda/src/handler.ts @@ -7,13 +7,11 @@ import type { } from "@nhs-notify-client-callbacks/models"; import { validateStatusPublishEvent } from "services/validators/event-validator"; import { transformEvent } from "services/transformers/event-transformer"; -import { extractCorrelationId, logger } from "services/logger"; +import { extractCorrelationId } from "services/logger"; import { ValidationError, getEventError } from "services/error-handler"; import type { ObservabilityService } from "services/observability"; import type { ConfigLoader } from "services/config-loader"; import { evaluateSubscriptionFilters } from "services/subscription-filter"; -import type { ApplicationsMapService } from "services/ssm-applications-map"; -import { signPayload } from "services/payload-signer"; const BATCH_CONCURRENCY = Number(process.env.BATCH_CONCURRENCY) || 10; const MESSAGE_ROOT_URI = process.env.MESSAGE_ROOT_URI ?? ""; @@ -27,20 +25,9 @@ type FilteredEvent = UnsignedEvent & { 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 { @@ -140,79 +127,6 @@ function processSingleEvent( type ClientConfigMap = Map; -async function signBatch( - filteredEvents: FilteredEvent[], - applicationsMapService: ApplicationsMapService, - configByClientId: ClientConfigMap, - stats: BatchStats, - observability: ObservabilityService, -): Promise { - const results = await pMap( - filteredEvents, - async (event): Promise => { - const { clientId } = event.data; - const correlationId = extractCorrelationId(event) ?? event.id; - - const applicationId = - await applicationsMapService.getApplicationId(clientId); - if (!applicationId) { - stats.recordFiltered(); - logger.warn( - "No applicationId found in SSM map - event will not be delivered", - { clientId, correlationId }, - ); - return undefined; - } - - const clientConfig = configByClientId.get(clientId); - const targetsById = new Map( - (clientConfig?.targets ?? []).map((t) => [t.targetId, t]), - ); - - const signaturesByTarget = new Map(); - - for (const targetId of event.targetIds) { - const target = targetsById.get(targetId); - const apiKey = target?.apiKey?.headerValue; - if (!apiKey) { - throw new ValidationError( - `Missing apiKey for target ${targetId}`, - correlationId, - ); - } - const signature = signPayload( - event.transformedPayload, - applicationId, - apiKey, - ); - signaturesByTarget.set(targetId.replaceAll("-", "_"), signature); - observability.recordCallbackSigned( - event.transformedPayload, - correlationId, - clientId, - signature, - ); - } - - return { - transformedEvent: { - payload: event.transformedPayload, - subscriptions: event.subscriptionIds, - signatures: Object.fromEntries(signaturesByTarget), - }, - deliveryContext: { - correlationId, - eventType: event.type, - clientId, - messageId: event.data.messageId, - }, - }; - }, - { concurrency: BATCH_CONCURRENCY }, - ); - return results.filter((e): e is SignedEvent => e !== undefined); -} - async function loadClientConfigs( events: UnsignedEvent[], configLoader: ConfigLoader, @@ -304,7 +218,6 @@ export async function processEvents( event: SQSRecord[], observability: ObservabilityService, configLoader: ConfigLoader, - applicationsMapService: ApplicationsMapService, ): Promise { const startTime = Date.now(); const stats = new BatchStats(); @@ -324,20 +237,21 @@ export async function processEvents( stats, ); - const signedEvents = await signBatch( - filteredEvents, - applicationsMapService, - configByClientId, - stats, - observability, - ); - - for (const signedEvent of signedEvents) { - observability.recordDeliveryInitiated(signedEvent.deliveryContext); - } + const deliverableEvents: TransformedEvent[] = filteredEvents.map( + (filteredEvent) => { + const correlationId = extractCorrelationId(filteredEvent); + observability.recordDeliveryInitiated({ + correlationId, + eventType: filteredEvent.type, + clientId: filteredEvent.data.clientId, + messageId: filteredEvent.data.messageId, + }); - const deliverableEvents = signedEvents.map( - (signedEvent) => signedEvent.transformedEvent, + return { + payload: filteredEvent.transformedPayload, + subscriptions: filteredEvent.subscriptionIds, + }; + }, ); const processingTime = Date.now() - startTime; diff --git a/lambdas/client-transform-filter-lambda/src/index.ts b/lambdas/client-transform-filter-lambda/src/index.ts index 9d631bfe..5ef8e197 100644 --- a/lambdas/client-transform-filter-lambda/src/index.ts +++ b/lambdas/client-transform-filter-lambda/src/index.ts @@ -3,17 +3,13 @@ import { Logger } from "services/logger"; import { CallbackMetrics, createMetricLogger } from "services/metrics"; import { ObservabilityService } from "services/observability"; import { ConfigLoaderService } from "services/config-loader-service"; -import { ApplicationsMapService } from "services/ssm-applications-map"; import { type TransformedEvent, processEvents } from "handler"; export const configLoaderService = new ConfigLoaderService(); -export const applicationsMapService = new ApplicationsMapService(); - export interface HandlerDependencies { createObservabilityService?: () => ObservabilityService; createConfigLoaderService?: () => ConfigLoaderService; - createApplicationsMapService?: () => ApplicationsMapService; } function createDefaultObservabilityService(): ObservabilityService { @@ -28,10 +24,6 @@ function createDefaultConfigLoaderService(): ConfigLoaderService { return configLoaderService; } -function createDefaultApplicationsMapService(): ApplicationsMapService { - return applicationsMapService; -} - export function createHandler( dependencies: Partial = {}, ): (event: SQSRecord[]) => Promise { @@ -41,19 +33,10 @@ export function createHandler( const configLoader = ( dependencies.createConfigLoaderService ?? createDefaultConfigLoaderService )(); - const applicationsMap = ( - dependencies.createApplicationsMapService ?? - createDefaultApplicationsMapService - )(); return async (event: SQSRecord[]): Promise => { const observability = createObservabilityService(); - return processEvents( - event, - observability, - configLoader.getLoader(), - applicationsMap, - ); + return processEvents(event, observability, configLoader.getLoader()); }; } diff --git a/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts index b0af71b0..43f760c8 100644 --- a/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts +++ b/lambdas/client-transform-filter-lambda/src/services/config-loader-service.ts @@ -1,5 +1,5 @@ import { S3Client } from "@aws-sdk/client-s3"; -import { ConfigCache } from "services/config-cache"; +import { ConfigCache } from "@nhs-notify-client-callbacks/config-cache"; import { ConfigLoader } from "services/config-loader"; const DEFAULT_CACHE_TTL_SECONDS = 60; diff --git a/lambdas/client-transform-filter-lambda/src/services/config-loader.ts b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts index 2d5b388f..76a5380d 100644 --- a/lambdas/client-transform-filter-lambda/src/services/config-loader.ts +++ b/lambdas/client-transform-filter-lambda/src/services/config-loader.ts @@ -1,6 +1,6 @@ import { GetObjectCommand, NoSuchKey, S3Client } from "@aws-sdk/client-s3"; import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; -import { ConfigCache } from "services/config-cache"; +import { ConfigCache } from "@nhs-notify-client-callbacks/config-cache"; import { logger } from "services/logger"; import { wrapUnknownError } from "services/error-handler"; import { diff --git a/lambdas/client-transform-filter-lambda/src/services/observability.ts b/lambdas/client-transform-filter-lambda/src/services/observability.ts index 4cfbf469..efd55eea 100644 --- a/lambdas/client-transform-filter-lambda/src/services/observability.ts +++ b/lambdas/client-transform-filter-lambda/src/services/observability.ts @@ -1,9 +1,6 @@ import type { MetricsLogger } from "aws-embedded-metrics"; import type { ClientCallbackPayload } from "@nhs-notify-client-callbacks/models"; -import { - logCallbackGenerated, - logCallbackSigned, -} from "services/callback-logger"; +import { logCallbackGenerated } from "services/callback-logger"; import type { Logger } from "services/logger"; import { logLifecycleEvent } from "services/logger"; import type { CallbackMetrics } from "services/metrics"; @@ -95,15 +92,6 @@ export class ObservabilityService { this.metrics.emitTransformationSuccess(); } - recordCallbackSigned( - payload: ClientCallbackPayload, - correlationId: string | undefined, - clientId: string, - signature: string, - ): void { - logCallbackSigned(this.logger, payload, correlationId, clientId, signature); - } - createChild(context: { correlationId?: string; eventType: string; diff --git a/lambdas/https-client-lambda/jest.config.ts b/lambdas/https-client-lambda/jest.config.ts new file mode 100644 index 00000000..cd0ed08e --- /dev/null +++ b/lambdas/https-client-lambda/jest.config.ts @@ -0,0 +1,9 @@ +import { nodeJestConfig } from "../../jest.config.base.ts"; + +export default { + ...nodeJestConfig, + transform: { + ...nodeJestConfig.transform, + "\\.lua$": "/lua-transform.js", + }, +}; diff --git a/lambdas/https-client-lambda/lua-transform.js b/lambdas/https-client-lambda/lua-transform.js new file mode 100644 index 00000000..e6e0a1c9 --- /dev/null +++ b/lambdas/https-client-lambda/lua-transform.js @@ -0,0 +1,7 @@ +module.exports = { + process(sourceText) { + return { + code: `module.exports = ${JSON.stringify(sourceText)};`, + }; + }, +}; diff --git a/lambdas/https-client-lambda/package.json b/lambdas/https-client-lambda/package.json new file mode 100644 index 00000000..99a65c77 --- /dev/null +++ b/lambdas/https-client-lambda/package.json @@ -0,0 +1,36 @@ +{ + "dependencies": { + "@aws-sdk/client-s3": "catalog:aws", + "@aws-sdk/client-secrets-manager": "catalog:aws", + "@aws-sdk/client-sqs": "catalog:aws", + "@aws-sdk/client-ssm": "catalog:aws", + "@nhs-notify-client-callbacks/config-cache": "workspace:*", + "@nhs-notify-client-callbacks/logger": "workspace:*", + "@nhs-notify-client-callbacks/models": "workspace:*", + "@redis/client": "catalog:app", + "aws-embedded-metrics": "catalog:app", + "esbuild": "catalog:tools" + }, + "devDependencies": { + "@tsconfig/node22": "catalog:tools", + "@types/aws-lambda": "catalog:tools", + "@types/jest": "catalog:test", + "@types/node": "catalog:tools", + "eslint": "catalog:lint", + "jest": "catalog:test", + "typescript": "catalog:tools" + }, + "engines": { + "node": ">=24.14.1" + }, + "name": "@nhs-notify-client-callbacks/https-client-lambda", + "private": true, + "scripts": { + "lambda-build": "rm -rf dist && pnpm exec esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --loader:.lua=text --entry-names=[name] --outdir=dist src/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "version": "0.0.1" +} diff --git a/lambdas/https-client-lambda/src/__tests__/config-loader.test.ts b/lambdas/https-client-lambda/src/__tests__/config-loader.test.ts new file mode 100644 index 00000000..4297e85e --- /dev/null +++ b/lambdas/https-client-lambda/src/__tests__/config-loader.test.ts @@ -0,0 +1,139 @@ +import { GetObjectCommand } from "@aws-sdk/client-s3"; + +import { loadTargetConfig, resetCache } from "services/config-loader"; + +const mockS3Send = jest.fn(); +jest.mock("@aws-sdk/client-s3", () => { + const actual = jest.requireActual("@aws-sdk/client-s3"); + return { + ...actual, + S3Client: jest.fn().mockImplementation(() => ({ + send: (...args: unknown[]) => mockS3Send(...args), + })), + }; +}); + +jest.mock("services/logger", () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +process.env.CLIENT_CONFIG_BUCKET = "test-bucket"; +process.env.CONFIG_CACHE_TTL_MS = "1000"; + +const VALID_TARGET = { + targetId: "target-1", + type: "API" as const, + invocationEndpoint: "https://webhook.example.invalid", + invocationMethod: "POST" as const, + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + mtls: { enabled: false }, + certPinning: { enabled: false }, +}; + +const VALID_CONFIG = { + clientId: "client-1", + subscriptions: [], + targets: [VALID_TARGET], +}; + +const makeS3Response = (body: unknown) => ({ + Body: { + transformToString: jest.fn().mockResolvedValue(JSON.stringify(body)), + }, +}); + +describe("loadTargetConfig", () => { + beforeEach(() => { + mockS3Send.mockReset(); + resetCache(); + }); + + it("parses valid S3 config and returns the matching target", async () => { + mockS3Send.mockResolvedValue(makeS3Response(VALID_CONFIG)); + + const result = await loadTargetConfig("client-1", "target-1"); + + expect(result).toEqual(VALID_TARGET); + expect(mockS3Send).toHaveBeenCalledTimes(1); + expect(mockS3Send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); + }); + + it("rejects config missing required field", async () => { + // eslint-disable-next-line @typescript-eslint/naming-convention, sonarjs/no-unused-vars -- destructuring to exclude mtls + const { mtls: _unusedMtls, ...targetWithoutMtls } = VALID_TARGET; + const invalidConfig = { + ...VALID_CONFIG, + targets: [targetWithoutMtls], + }; + mockS3Send.mockResolvedValue(makeS3Response(invalidConfig)); + + await expect(loadTargetConfig("client-1", "target-1")).rejects.toThrow( + "Invalid client config for 'client-1'", + ); + }); + + it("returns cached value without S3 call on subsequent requests", async () => { + mockS3Send.mockResolvedValue(makeS3Response(VALID_CONFIG)); + + await loadTargetConfig("client-1", "target-1"); + await loadTargetConfig("client-1", "target-1"); + + expect(mockS3Send).toHaveBeenCalledTimes(1); + }); + + it("re-fetches from S3 after TTL expiry", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-01-01T10:00:00Z")); + + mockS3Send.mockResolvedValue(makeS3Response(VALID_CONFIG)); + + await loadTargetConfig("client-1", "target-1"); + + jest.advanceTimersByTime(1001); + + await loadTargetConfig("client-1", "target-1"); + + expect(mockS3Send).toHaveBeenCalledTimes(2); + + jest.useRealTimers(); + }); + + it("throws when CLIENT_CONFIG_BUCKET is not set", async () => { + let loadFn: typeof loadTargetConfig; + const saved = process.env.CLIENT_CONFIG_BUCKET; + delete process.env.CLIENT_CONFIG_BUCKET; + + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- jest.isolateModules requires synchronous require + loadFn = require("services/config-loader").loadTargetConfig; + }); + + await expect(loadFn!("client-1", "target-1")).rejects.toThrow( + "CLIENT_CONFIG_BUCKET is required", + ); + + process.env.CLIENT_CONFIG_BUCKET = saved; + }); + + it("throws when S3 response body is empty", async () => { + mockS3Send.mockResolvedValue({ Body: undefined }); + + await expect(loadTargetConfig("client-1", "target-1")).rejects.toThrow( + "S3 response body was empty for client 'client-1'", + ); + }); + + it("throws when target not found in config", async () => { + mockS3Send.mockResolvedValue(makeS3Response(VALID_CONFIG)); + + await expect(loadTargetConfig("client-1", "nonexistent")).rejects.toThrow( + "Target 'nonexistent' not found in config for client 'client-1'", + ); + }); +}); diff --git a/lambdas/https-client-lambda/src/__tests__/delivery-metrics.test.ts b/lambdas/https-client-lambda/src/__tests__/delivery-metrics.test.ts new file mode 100644 index 00000000..01130580 --- /dev/null +++ b/lambdas/https-client-lambda/src/__tests__/delivery-metrics.test.ts @@ -0,0 +1,174 @@ +const mockCreateMetricsLogger = jest.fn(); +jest.mock("aws-embedded-metrics", () => ({ + Unit: { Count: "Count" }, + createMetricsLogger: () => mockCreateMetricsLogger(), +})); + +describe("delivery-metrics", () => { + const mockMetrics = { + setNamespace: jest.fn(), + setDimensions: jest.fn(), + setProperty: jest.fn(), + putMetric: jest.fn(), + flush: jest.fn().mockResolvedValue(undefined), + }; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + mockCreateMetricsLogger.mockReturnValue(mockMetrics); + process.env.METRICS_NAMESPACE = "TestNamespace"; + process.env.ENVIRONMENT = "test"; + }); + + afterEach(() => { + delete process.env.METRICS_NAMESPACE; + delete process.env.ENVIRONMENT; + }); + + it("throws when METRICS_NAMESPACE is not set", async () => { + delete process.env.METRICS_NAMESPACE; + // @ts-expect-error -- modulePaths resolves at runtime + const { emitDeliveryAttempt } = await import("services/delivery-metrics"); + + expect(() => emitDeliveryAttempt("t-1")).toThrow( + "METRICS_NAMESPACE environment variable is not set", + ); + }); + + it("throws when ENVIRONMENT is not set", async () => { + delete process.env.ENVIRONMENT; + // @ts-expect-error -- modulePaths resolves at runtime + const { emitDeliveryAttempt } = await import("services/delivery-metrics"); + + expect(() => emitDeliveryAttempt("t-1")).toThrow( + "ENVIRONMENT environment variable is not set", + ); + }); + + it("creates metrics logger with correct namespace and dimensions", async () => { + // @ts-expect-error -- modulePaths resolves at runtime + const { emitDeliveryAttempt } = await import("services/delivery-metrics"); + + emitDeliveryAttempt("t-1"); + + expect(mockMetrics.setNamespace).toHaveBeenCalledWith("TestNamespace"); + expect(mockMetrics.setDimensions).toHaveBeenCalledWith({ + Environment: "test", + }); + }); + + it("caches the metrics logger on subsequent calls", async () => { + // @ts-expect-error -- modulePaths resolves at runtime + const mod = await import("services/delivery-metrics"); + const { emitDeliveryAttempt, emitDeliverySuccess } = mod; + + emitDeliveryAttempt("t-1"); + emitDeliverySuccess("t-1"); + + expect(mockCreateMetricsLogger).toHaveBeenCalledTimes(1); + }); + + it("emitDeliveryAttempt emits correct metric", async () => { + // @ts-expect-error -- modulePaths resolves at runtime + const { emitDeliveryAttempt } = await import("services/delivery-metrics"); + + emitDeliveryAttempt("target-42"); + + expect(mockMetrics.setProperty).toHaveBeenCalledWith( + "targetId", + "target-42", + ); + expect(mockMetrics.putMetric).toHaveBeenCalledWith( + "DeliveryAttempt", + 1, + "Count", + ); + }); + + it("emitDeliverySuccess emits correct metric", async () => { + // @ts-expect-error -- modulePaths resolves at runtime + const { emitDeliverySuccess } = await import("services/delivery-metrics"); + + emitDeliverySuccess("target-42"); + + expect(mockMetrics.putMetric).toHaveBeenCalledWith( + "DeliverySuccess", + 1, + "Count", + ); + }); + + it("emitDeliveryFailure emits correct metric", async () => { + // @ts-expect-error -- modulePaths resolves at runtime + const { emitDeliveryFailure } = await import("services/delivery-metrics"); + + emitDeliveryFailure("target-42"); + + expect(mockMetrics.putMetric).toHaveBeenCalledWith( + "DeliveryFailure", + 1, + "Count", + ); + }); + + it("emitDeliveryPermanentFailure emits correct metric", async () => { + // @ts-expect-error -- modulePaths resolves at runtime + const mod = await import("services/delivery-metrics"); + const { emitDeliveryPermanentFailure } = mod; + + emitDeliveryPermanentFailure("target-42"); + + expect(mockMetrics.putMetric).toHaveBeenCalledWith( + "DeliveryPermanentFailure", + 1, + "Count", + ); + }); + + it("emitCircuitBreakerOpen emits correct metric", async () => { + // @ts-expect-error -- modulePaths resolves at runtime + const mod = await import("services/delivery-metrics"); + const { emitCircuitBreakerOpen } = mod; + + emitCircuitBreakerOpen("target-42"); + + expect(mockMetrics.putMetric).toHaveBeenCalledWith( + "CircuitBreakerOpen", + 1, + "Count", + ); + }); + + it("flushMetrics calls flush on the instance", async () => { + // @ts-expect-error -- modulePaths resolves at runtime + const mod = await import("services/delivery-metrics"); + const { emitDeliveryAttempt, flushMetrics } = mod; + + emitDeliveryAttempt("t-1"); + await flushMetrics(); + + expect(mockMetrics.flush).toHaveBeenCalled(); + }); + + it("flushMetrics does nothing when no metrics instance exists", async () => { + // @ts-expect-error -- modulePaths resolves at runtime + const { flushMetrics } = await import("services/delivery-metrics"); + + await flushMetrics(); + + expect(mockMetrics.flush).not.toHaveBeenCalled(); + }); + + it("resetMetrics clears the cached instance", async () => { + // @ts-expect-error -- modulePaths resolves at runtime + const mod = await import("services/delivery-metrics"); + const { emitDeliveryAttempt, resetMetrics } = mod; + + emitDeliveryAttempt("t-1"); + resetMetrics(); + emitDeliveryAttempt("t-2"); + + expect(mockCreateMetricsLogger).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lambdas/https-client-lambda/src/__tests__/dlq-sender.test.ts b/lambdas/https-client-lambda/src/__tests__/dlq-sender.test.ts new file mode 100644 index 00000000..21ae3700 --- /dev/null +++ b/lambdas/https-client-lambda/src/__tests__/dlq-sender.test.ts @@ -0,0 +1,57 @@ +import { SendMessageCommand } from "@aws-sdk/client-sqs"; + +import { sendToDlq } from "services/dlq-sender"; + +const mockSend = jest.fn(); +jest.mock("@aws-sdk/client-sqs", () => { + const actual = jest.requireActual("@aws-sdk/client-sqs"); + return { + ...actual, + SQSClient: jest.fn().mockImplementation(() => ({ + send: (...args: unknown[]) => mockSend(...args), + })), + }; +}); + +process.env.DLQ_URL = "https://sqs.eu-west-2.invalid/123456789/test-dlq"; + +describe("sendToDlq", () => { + beforeEach(() => { + mockSend.mockReset(); + }); + + it("sends SendMessageCommand with correct QueueUrl and MessageBody", async () => { + mockSend.mockResolvedValue({}); + + await sendToDlq('{"test":"message"}'); + + expect(mockSend).toHaveBeenCalledTimes(1); + const command = mockSend.mock.calls[0][0]; + expect(command).toBeInstanceOf(SendMessageCommand); + expect(command.input).toEqual({ + QueueUrl: "https://sqs.eu-west-2.invalid/123456789/test-dlq", + MessageBody: '{"test":"message"}', + }); + }); + + it("surfaces SDK errors", async () => { + mockSend.mockRejectedValue(new Error("SQS send failed")); + + await expect(sendToDlq("body")).rejects.toThrow("SQS send failed"); + }); + + it("throws when DLQ_URL is not set", async () => { + let sendFn: typeof sendToDlq; + const saved = process.env.DLQ_URL; + delete process.env.DLQ_URL; + + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- jest.isolateModules requires synchronous require + sendFn = require("services/dlq-sender").sendToDlq; + }); + + await expect(sendFn!("body")).rejects.toThrow("DLQ_URL is required"); + + process.env.DLQ_URL = saved; + }); +}); diff --git a/lambdas/https-client-lambda/src/__tests__/endpoint-gate.test.ts b/lambdas/https-client-lambda/src/__tests__/endpoint-gate.test.ts new file mode 100644 index 00000000..68280a5e --- /dev/null +++ b/lambdas/https-client-lambda/src/__tests__/endpoint-gate.test.ts @@ -0,0 +1,278 @@ +import { + type EndpointGateConfig, + admit, + getRedisClient, + recordResult, + resetAdmitSha, + resetRedisClient, +} from "services/endpoint-gate"; + +jest.mock("services/logger"); + +const mockSendCommand = jest.fn(); +const mockConnect = jest.fn().mockResolvedValue(undefined); +const mockOn = jest.fn(); + +jest.mock("@redis/client", () => ({ + createClient: jest.fn(() => ({ + sendCommand: mockSendCommand, + connect: mockConnect, + on: mockOn, + isOpen: true, + })), +})); + +const defaultConfig: EndpointGateConfig = { + burstCapacity: 10, + cbProbeIntervalMs: 60_000, + decayPeriodMs: 300_000, + cbWindowPeriodMs: 60_000, + cbErrorThreshold: 0.5, + cbMinAttempts: 10, + cbCooldownMs: 60_000, +}; + +const mockRedis = { + sendCommand: mockSendCommand, + connect: mockConnect, + on: mockOn, + isOpen: true, +} as never; + +beforeEach(() => { + jest.clearAllMocks(); + resetAdmitSha(); +}); + +describe("admit", () => { + it("returns allowed when tokens available", async () => { + mockSendCommand.mockResolvedValueOnce( + JSON.stringify({ allowed: true, probe: false, effectiveRate: 10 }), + ); + + const result = await admit(mockRedis, "target-1", 10, true, defaultConfig); + + expect(result).toEqual({ allowed: true, probe: false, effectiveRate: 10 }); + expect(mockSendCommand).toHaveBeenCalledWith( + expect.arrayContaining(["EVALSHA"]), + ); + }); + + it("returns rate_limited when tokens exhausted", async () => { + mockSendCommand.mockResolvedValueOnce( + JSON.stringify({ + allowed: false, + reason: "rate_limited", + retryAfterMs: 500, + effectiveRate: 10, + }), + ); + + const result = await admit(mockRedis, "target-1", 10, false, defaultConfig); + + expect(result).toEqual({ + allowed: false, + reason: "rate_limited", + retryAfterMs: 500, + effectiveRate: 10, + }); + }); + + it("returns circuit_open with probe slot available", async () => { + mockSendCommand.mockResolvedValueOnce( + JSON.stringify({ allowed: true, probe: true, effectiveRate: 0 }), + ); + + const result = await admit(mockRedis, "target-1", 10, true, defaultConfig); + + expect(result).toEqual({ allowed: true, probe: true, effectiveRate: 0 }); + }); + + it("returns circuit_open without probe slot", async () => { + mockSendCommand.mockResolvedValueOnce( + JSON.stringify({ + allowed: false, + reason: "circuit_open", + retryAfterMs: 30_000, + effectiveRate: 0, + }), + ); + + const result = await admit(mockRedis, "target-1", 10, true, defaultConfig); + + expect(result).toEqual({ + allowed: false, + reason: "circuit_open", + retryAfterMs: 30_000, + effectiveRate: 0, + }); + }); + + it("falls back to EVAL on NOSCRIPT error", async () => { + mockSendCommand + .mockRejectedValueOnce(new Error("NOSCRIPT No matching script")) + .mockResolvedValueOnce( + JSON.stringify({ allowed: true, probe: false, effectiveRate: 10 }), + ); + + const result = await admit(mockRedis, "target-1", 10, true, defaultConfig); + + expect(result).toEqual({ allowed: true, probe: false, effectiveRate: 10 }); + expect(mockSendCommand).toHaveBeenCalledTimes(2); + expect(mockSendCommand).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining(["EVALSHA"]), + ); + expect(mockSendCommand).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining(["EVAL"]), + ); + }); + + it("propagates non-NOSCRIPT Redis errors", async () => { + mockSendCommand.mockRejectedValueOnce(new Error("Connection refused")); + + await expect( + admit(mockRedis, "target-1", 10, true, defaultConfig), + ).rejects.toThrow("Connection refused"); + }); + + it("passes cbEnabled=0 when circuit breaker is disabled", async () => { + mockSendCommand.mockResolvedValueOnce( + JSON.stringify({ allowed: true, probe: false, effectiveRate: 10 }), + ); + + await admit(mockRedis, "target-1", 10, false, defaultConfig); + + const args = mockSendCommand.mock.calls[0]![0] as string[]; + const cbEnabledArg = args[9]; + expect(cbEnabledArg).toBe("0"); + }); + + it("passes correct keys for target-specific hashes", async () => { + mockSendCommand.mockResolvedValueOnce( + JSON.stringify({ allowed: true, probe: false, effectiveRate: 5 }), + ); + + await admit(mockRedis, "my-target", 5, true, defaultConfig); + + const args = mockSendCommand.mock.calls[0]![0] as string[]; + expect(args[3]).toBe("rl:my-target"); + expect(args[4]).toBe("cb:my-target"); + }); +}); + +describe("recordResult", () => { + it("returns closed on success below threshold", async () => { + mockSendCommand.mockResolvedValueOnce( + JSON.stringify({ ok: true, state: "closed" }), + ); + + const result = await recordResult( + mockRedis, + "target-1", + true, + defaultConfig, + ); + + expect(result).toEqual({ ok: true, state: "closed" }); + expect(mockSendCommand).toHaveBeenCalledWith( + expect.arrayContaining(["EVALSHA"]), + ); + }); + + it("returns opened when failure crosses threshold", async () => { + mockSendCommand.mockResolvedValueOnce( + JSON.stringify({ ok: false, state: "opened" }), + ); + + const result = await recordResult( + mockRedis, + "target-1", + false, + defaultConfig, + ); + + expect(result).toEqual({ ok: false, state: "opened" }); + }); + + it("falls back to EVAL on NOSCRIPT error", async () => { + mockSendCommand + .mockRejectedValueOnce(new Error("NOSCRIPT No matching script")) + .mockResolvedValueOnce(JSON.stringify({ ok: true, state: "closed" })); + + const result = await recordResult( + mockRedis, + "target-1", + true, + defaultConfig, + ); + + expect(result).toEqual({ ok: true, state: "closed" }); + expect(mockSendCommand).toHaveBeenCalledTimes(2); + }); + + it("propagates non-NOSCRIPT Redis errors", async () => { + mockSendCommand.mockRejectedValueOnce(new Error("Connection refused")); + + await expect( + recordResult(mockRedis, "target-1", false, defaultConfig), + ).rejects.toThrow("Connection refused"); + }); + + it("passes correct cb key for target", async () => { + mockSendCommand.mockResolvedValueOnce( + JSON.stringify({ ok: true, state: "closed" }), + ); + + await recordResult(mockRedis, "my-target", true, defaultConfig); + + const args = mockSendCommand.mock.calls[0]![0] as string[]; + expect(args[3]).toBe("cb:my-target"); + }); +}); + +describe("getRedisClient", () => { + beforeEach(() => { + resetRedisClient(); + delete process.env.ELASTICACHE_ENDPOINT; + }); + + it("throws when ELASTICACHE_ENDPOINT is not set", async () => { + await expect(getRedisClient()).rejects.toThrow( + "ELASTICACHE_ENDPOINT is required", + ); + }); + + it("creates and connects a Redis client", async () => { + process.env.ELASTICACHE_ENDPOINT = "localhost"; + + const client = await getRedisClient(); + + expect(client).toBeDefined(); + expect(mockConnect).toHaveBeenCalled(); + }); + + it("returns cached client when already open", async () => { + process.env.ELASTICACHE_ENDPOINT = "localhost"; + + const first = await getRedisClient(); + const second = await getRedisClient(); + + expect(first).toBe(second); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it("registers error handler on client", async () => { + process.env.ELASTICACHE_ENDPOINT = "localhost"; + + await getRedisClient(); + + expect(mockOn).toHaveBeenCalledWith("error", expect.any(Function)); + + const errorHandler = mockOn.mock.calls.find( + (c: unknown[]) => c[0] === "error", + )![1] as (err: Error) => void; + errorHandler(new Error("test error")); + }); +}); diff --git a/lambdas/https-client-lambda/src/__tests__/handler.test.ts b/lambdas/https-client-lambda/src/__tests__/handler.test.ts new file mode 100644 index 00000000..2885a7e1 --- /dev/null +++ b/lambdas/https-client-lambda/src/__tests__/handler.test.ts @@ -0,0 +1,474 @@ +import type { SQSRecord } from "aws-lambda"; + +import { processRecords } from "handler"; + +jest.mock("services/logger", () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +const mockLoadTargetConfig = jest.fn(); +jest.mock("services/config-loader", () => ({ + loadTargetConfig: (...args: unknown[]) => mockLoadTargetConfig(...args), +})); + +const mockGetApplicationId = jest.fn(); +jest.mock("services/ssm-applications-map", () => ({ + getApplicationId: (...args: unknown[]) => mockGetApplicationId(...args), +})); + +const mockSignPayload = jest.fn(); +jest.mock("services/payload-signer", () => ({ + signPayload: (...args: unknown[]) => mockSignPayload(...args), +})); + +const mockBuildAgent = jest.fn(); +jest.mock("services/delivery/tls-agent-factory", () => ({ + buildAgent: (...args: unknown[]) => mockBuildAgent(...args), +})); + +const mockDeliverPayload = jest.fn(); +jest.mock("services/delivery/https-client", () => ({ + deliverPayload: (...args: unknown[]) => mockDeliverPayload(...args), +})); + +const mockSendToDlq = jest.fn(); +jest.mock("services/dlq-sender", () => ({ + sendToDlq: (...args: unknown[]) => mockSendToDlq(...args), +})); + +const mockChangeVisibility = jest.fn(); +jest.mock("services/sqs-visibility", () => ({ + changeVisibility: (...args: unknown[]) => mockChangeVisibility(...args), +})); + +const mockJitteredBackoff = jest.fn(); +const mockParseRetryAfter = jest.fn(); +const mockIsWindowExhausted = jest.fn(); +const mockExceedsSqsMax = jest.fn(); +jest.mock("services/delivery/retry-policy", () => ({ + jitteredBackoffSeconds: (...args: unknown[]) => mockJitteredBackoff(...args), + parseRetryAfter: (...args: unknown[]) => mockParseRetryAfter(...args), + isWindowExhausted: (...args: unknown[]) => mockIsWindowExhausted(...args), + exceedsSqsMaxVisibility: (...args: unknown[]) => mockExceedsSqsMax(...args), +})); + +const mockAdmit = jest.fn(); +const mockGetRedisClient = jest.fn(); +const mockRecordResult = jest.fn(); +jest.mock("services/endpoint-gate", () => ({ + admit: (...args: unknown[]) => mockAdmit(...args), + recordResult: (...args: unknown[]) => mockRecordResult(...args), + getRedisClient: (...args: unknown[]) => mockGetRedisClient(...args), +})); + +jest.mock("services/delivery-metrics", () => ({ + emitDeliveryAttempt: jest.fn(), + emitDeliverySuccess: jest.fn(), + emitDeliveryFailure: jest.fn(), + emitDeliveryPermanentFailure: jest.fn(), + emitCircuitBreakerOpen: jest.fn(), + flushMetrics: jest.fn().mockResolvedValue(undefined), +})); + +process.env.CLIENT_ID = "client-1"; + +const TARGET = { + targetId: "target-1", + type: "API" as const, + invocationEndpoint: "https://webhook.example.invalid", + invocationMethod: "POST" as const, + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret-key" }, + mtls: { enabled: true }, + certPinning: { enabled: false }, +}; + +const makeRecord = (overrides: Partial = {}): SQSRecord => ({ + messageId: "msg-1", + receiptHandle: "receipt-1", + body: JSON.stringify({ + payload: { + data: [ + { type: "MessageStatus", attributes: { messageStatus: "delivered" } }, + ], + }, + subscriptions: ["sub-1"], + targetId: "target-1", + }), + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: "0", + SenderId: "sender", + ApproximateFirstReceiveTimestamp: "0", + }, + messageAttributes: {}, + md5OfBody: "abc", + eventSource: "aws:sqs", + eventSourceARN: "arn:aws:sqs:eu-west-2:123:queue", + awsRegion: "eu-west-2", + ...overrides, +}); + +describe("processRecords", () => { + const mockAgent = {}; + + beforeEach(() => { + jest.clearAllMocks(); + mockLoadTargetConfig.mockResolvedValue(TARGET); + mockGetApplicationId.mockResolvedValue("app-id-1"); + mockSignPayload.mockReturnValue("signature-abc"); + mockBuildAgent.mockResolvedValue(mockAgent); + mockDeliverPayload.mockResolvedValue({ ok: true }); + mockSendToDlq.mockResolvedValue(undefined); + mockChangeVisibility.mockResolvedValue(undefined); + mockJitteredBackoff.mockReturnValue(5); + mockParseRetryAfter.mockReturnValue(60); + mockIsWindowExhausted.mockReturnValue(false); + mockExceedsSqsMax.mockReturnValue(false); + mockGetRedisClient.mockResolvedValue({}); + mockAdmit.mockResolvedValue({ + allowed: true, + probe: false, + effectiveRate: 10, + }); + mockRecordResult.mockResolvedValue({ ok: true, state: "closed" }); + }); + + it("returns no failures on successful delivery", async () => { + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([]); + expect(mockLoadTargetConfig).toHaveBeenCalledWith("client-1", "target-1"); + expect(mockGetApplicationId).toHaveBeenCalledWith("client-1"); + expect(mockSignPayload).toHaveBeenCalledWith( + "app-id-1", + "secret-key", + expect.objectContaining({ data: expect.any(Array) }), + ); + expect(mockBuildAgent).toHaveBeenCalledWith(TARGET); + expect(mockDeliverPayload).toHaveBeenCalledWith( + TARGET, + expect.any(String), + "signature-abc", + mockAgent, + ); + }); + + it("sends permanent failure to DLQ and returns no failure", async () => { + mockDeliverPayload.mockResolvedValue({ ok: false, permanent: true }); + + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([]); + expect(mockSendToDlq).toHaveBeenCalledWith(makeRecord().body); + }); + + it("returns failure for transient 5xx errors", async () => { + mockDeliverPayload.mockResolvedValue({ + ok: false, + permanent: false, + statusCode: 503, + }); + + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([{ itemIdentifier: "msg-1" }]); + }); + + it("returns failure for 429 rate-limited responses", async () => { + mockDeliverPayload.mockResolvedValue({ + ok: false, + permanent: false, + statusCode: 429, + retryAfterHeader: "60", + }); + + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([{ itemIdentifier: "msg-1" }]); + }); + + it("processes multiple records independently", async () => { + const record1 = makeRecord({ messageId: "msg-1" }); + const record2 = makeRecord({ messageId: "msg-2" }); + + mockDeliverPayload + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ + ok: false, + permanent: false, + statusCode: 500, + }); + + const failures = await processRecords([record1, record2]); + + expect(failures).toEqual([{ itemIdentifier: "msg-2" }]); + }); + + it("throws when CLIENT_ID is not set", async () => { + const saved = process.env.CLIENT_ID; + delete process.env.CLIENT_ID; + + let processFn: typeof processRecords; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- jest.isolateModules requires synchronous require + processFn = require("handler").processRecords; + }); + + const failures = await processFn!([makeRecord()]); + + expect(failures).toEqual([{ itemIdentifier: "msg-1" }]); + + process.env.CLIENT_ID = saved; + }); + + it("sends to DLQ when retry window is exhausted", async () => { + mockIsWindowExhausted.mockReturnValue(true); + + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([]); + expect(mockSendToDlq).toHaveBeenCalledWith(makeRecord().body); + expect(mockDeliverPayload).not.toHaveBeenCalled(); + }); + + it("calls changeVisibility with backoff on 5xx then throws", async () => { + mockDeliverPayload.mockResolvedValue({ + ok: false, + permanent: false, + statusCode: 503, + }); + + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([{ itemIdentifier: "msg-1" }]); + expect(mockChangeVisibility).toHaveBeenCalledWith("receipt-1", 5); + }); + + it("calls changeVisibility with retryAfter on 429", async () => { + mockDeliverPayload.mockResolvedValue({ + ok: false, + permanent: false, + statusCode: 429, + retryAfterHeader: "120", + }); + mockParseRetryAfter.mockReturnValue(120); + + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([{ itemIdentifier: "msg-1" }]); + expect(mockChangeVisibility).toHaveBeenCalledWith("receipt-1", 120); + }); + + it("sends 429 to DLQ when Retry-After exceeds SQS max", async () => { + mockDeliverPayload.mockResolvedValue({ + ok: false, + permanent: false, + statusCode: 429, + retryAfterHeader: "50000", + }); + mockParseRetryAfter.mockReturnValue(50_000); + mockExceedsSqsMax.mockReturnValue(true); + + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([]); + expect(mockSendToDlq).toHaveBeenCalledWith(makeRecord().body); + }); + + it("uses jittered backoff for 429 without Retry-After header", async () => { + mockDeliverPayload.mockResolvedValue({ + ok: false, + permanent: false, + statusCode: 429, + retryAfterHeader: undefined, + }); + + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([{ itemIdentifier: "msg-1" }]); + expect(mockJitteredBackoff).toHaveBeenCalled(); + expect(mockChangeVisibility).toHaveBeenCalledWith("receipt-1", 5); + }); + + it("requeues when rate limited by endpoint gate", async () => { + mockAdmit.mockResolvedValue({ + allowed: false, + reason: "rate_limited", + retryAfterMs: 2000, + effectiveRate: 10, + }); + + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([{ itemIdentifier: "msg-1" }]); + expect(mockChangeVisibility).toHaveBeenCalledWith("receipt-1", 2); + expect(mockSendToDlq).not.toHaveBeenCalled(); + expect(mockDeliverPayload).not.toHaveBeenCalled(); + }); + + it("requeues when circuit is open", async () => { + mockAdmit.mockResolvedValue({ + allowed: false, + reason: "circuit_open", + retryAfterMs: 30_000, + effectiveRate: 0, + }); + + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([{ itemIdentifier: "msg-1" }]); + expect(mockChangeVisibility).toHaveBeenCalledWith("receipt-1", 30); + expect(mockSendToDlq).not.toHaveBeenCalled(); + expect(mockDeliverPayload).not.toHaveBeenCalled(); + }); + + it("proceeds to delivery when circuit breaker is disabled", async () => { + const targetNoCb = { + ...TARGET, + delivery: { circuitBreaker: { enabled: false } }, + }; + mockLoadTargetConfig.mockResolvedValue(targetNoCb); + + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([]); + expect(mockAdmit).toHaveBeenCalledWith( + expect.anything(), + "target-1", + 10, + false, + expect.any(Object), + ); + expect(mockDeliverPayload).toHaveBeenCalled(); + }); + + it("calls recordResult(true) on successful delivery when CB enabled", async () => { + const targetCb = { + ...TARGET, + delivery: { circuitBreaker: { enabled: true } }, + }; + mockLoadTargetConfig.mockResolvedValue(targetCb); + + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([]); + expect(mockRecordResult).toHaveBeenCalledWith( + expect.anything(), + "target-1", + true, + expect.any(Object), + ); + }); + + it("calls recordResult(false) on 5xx before visibility change", async () => { + const targetCb = { + ...TARGET, + delivery: { circuitBreaker: { enabled: true } }, + }; + mockLoadTargetConfig.mockResolvedValue(targetCb); + mockDeliverPayload.mockResolvedValue({ + ok: false, + permanent: false, + statusCode: 503, + }); + + const failures = await processRecords([makeRecord()]); + + expect(failures).toEqual([{ itemIdentifier: "msg-1" }]); + expect(mockRecordResult).toHaveBeenCalledWith( + expect.anything(), + "target-1", + false, + expect.any(Object), + ); + expect(mockChangeVisibility).toHaveBeenCalled(); + }); + + it("does not call recordResult on rate-limited path", async () => { + mockAdmit.mockResolvedValue({ + allowed: false, + reason: "rate_limited", + retryAfterMs: 2000, + effectiveRate: 10, + }); + + await processRecords([makeRecord()]); + + expect(mockRecordResult).not.toHaveBeenCalled(); + }); + + it("does not call recordResult on 429 path", async () => { + mockDeliverPayload.mockResolvedValue({ + ok: false, + permanent: false, + statusCode: 429, + retryAfterHeader: "60", + }); + + await processRecords([makeRecord()]); + + expect(mockRecordResult).not.toHaveBeenCalled(); + }); + + it("does not call recordResult when CB is disabled on success", async () => { + const targetNoCb = { + ...TARGET, + delivery: { circuitBreaker: { enabled: false } }, + }; + mockLoadTargetConfig.mockResolvedValue(targetNoCb); + + await processRecords([makeRecord()]); + + expect(mockRecordResult).not.toHaveBeenCalled(); + }); + + it("emits CircuitBreakerOpen metric when recordResult returns opened", async () => { + const targetCb = { + ...TARGET, + delivery: { circuitBreaker: { enabled: true } }, + }; + mockLoadTargetConfig.mockResolvedValue(targetCb); + mockDeliverPayload.mockResolvedValue({ + ok: false, + permanent: false, + statusCode: 503, + }); + mockRecordResult.mockResolvedValue({ ok: false, state: "opened" }); + + const { emitCircuitBreakerOpen } = jest.requireMock( + "services/delivery-metrics", + ); + + await processRecords([makeRecord()]); + + expect(emitCircuitBreakerOpen).toHaveBeenCalledWith("target-1"); + }); + + it("does not emit CircuitBreakerOpen when recordResult returns closed", async () => { + const targetCb = { + ...TARGET, + delivery: { circuitBreaker: { enabled: true } }, + }; + mockLoadTargetConfig.mockResolvedValue(targetCb); + mockDeliverPayload.mockResolvedValue({ + ok: false, + permanent: false, + statusCode: 503, + }); + mockRecordResult.mockResolvedValue({ ok: true, state: "closed" }); + + const { emitCircuitBreakerOpen } = jest.requireMock( + "services/delivery-metrics", + ); + + await processRecords([makeRecord()]); + + expect(emitCircuitBreakerOpen).not.toHaveBeenCalled(); + }); +}); diff --git a/lambdas/https-client-lambda/src/__tests__/https-client.test.ts b/lambdas/https-client-lambda/src/__tests__/https-client.test.ts new file mode 100644 index 00000000..338363f5 --- /dev/null +++ b/lambdas/https-client-lambda/src/__tests__/https-client.test.ts @@ -0,0 +1,211 @@ +/* eslint-disable unicorn/prefer-event-target -- Node.js http module mock requires EventEmitter API */ +import { EventEmitter } from "node:events"; +import https, { Agent } from "node:https"; +import type { CallbackTarget } from "@nhs-notify-client-callbacks/models"; + +import { deliverPayload } from "services/delivery/https-client"; + +jest.mock("services/delivery/tls-agent-factory", () => ({ + PERMANENT_TLS_ERROR_CODES: new Set([ + "CERT_HAS_EXPIRED", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "SELF_SIGNED_CERT_IN_CHAIN", + "DEPTH_ZERO_SELF_SIGNED_CERT", + "ERR_TLS_CERT_ALTNAME_INVALID", + ]), +})); + +const createTarget = (): CallbackTarget => ({ + targetId: "target-1", + type: "API", + invocationEndpoint: "https://webhook.example.invalid:8443/callback", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + mtls: { enabled: false }, + certPinning: { enabled: false }, +}); + +const createMockAgent = () => ({}) as Agent; + +type MockResponse = EventEmitter & { + statusCode: number; + headers: Record; + resume: jest.Mock; +}; + +function mockHttpsRequest( + statusCode: number, + headers: Record = {}, +) { + const mockReq = new EventEmitter() as EventEmitter & { end: jest.Mock }; + mockReq.end = jest.fn(); + + jest.spyOn(https, "request").mockImplementation((_opts, callback) => { + const res: MockResponse = Object.assign(new EventEmitter(), { + statusCode, + headers, + resume: jest.fn(), + }); + + process.nextTick(() => (callback as (res: MockResponse) => void)(res)); + + return mockReq as unknown as ReturnType; + }); + + return mockReq; +} + +function mockHttpsRequestError(errorCode: string) { + const mockReq = new EventEmitter() as EventEmitter & { end: jest.Mock }; + mockReq.end = jest.fn(); + + jest.spyOn(https, "request").mockImplementation(() => { + process.nextTick(() => { + const error = new Error("TLS error") as NodeJS.ErrnoException; + error.code = errorCode; + mockReq.emit("error", error); + }); + + return mockReq as unknown as ReturnType; + }); + + return mockReq; +} + +describe("deliverPayload", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns ok: true on 2xx", async () => { + mockHttpsRequest(200); + + const result = await deliverPayload( + createTarget(), + '{"test":true}', + "sig-abc", + createMockAgent(), + ); + + expect(result).toEqual({ ok: true }); + }); + + it("returns permanent: true on 4xx non-429", async () => { + mockHttpsRequest(400); + + const result = await deliverPayload( + createTarget(), + '{"test":true}', + "sig-abc", + createMockAgent(), + ); + + expect(result).toEqual({ ok: false, permanent: true }); + }); + + it("returns permanent: true on TLS error CERT_HAS_EXPIRED", async () => { + mockHttpsRequestError("CERT_HAS_EXPIRED"); + + const result = await deliverPayload( + createTarget(), + '{"test":true}', + "sig-abc", + createMockAgent(), + ); + + expect(result).toEqual({ ok: false, permanent: true }); + }); + + it("returns permanent: true on TLS pinning error", async () => { + mockHttpsRequestError("ERR_CERT_PINNING_FAILED"); + + const result = await deliverPayload( + createTarget(), + '{"test":true}', + "sig-abc", + createMockAgent(), + ); + + expect(result).toEqual({ ok: false, permanent: true }); + }); + + it("returns ok: false, permanent: false on 5xx", async () => { + mockHttpsRequest(503); + + const result = await deliverPayload( + createTarget(), + '{"test":true}', + "sig-abc", + createMockAgent(), + ); + + expect(result).toEqual({ ok: false, permanent: false, statusCode: 503 }); + }); + + it("returns 429 with Retry-After header value", async () => { + mockHttpsRequest(429, { "retry-after": "60" }); + + const result = await deliverPayload( + createTarget(), + '{"test":true}', + "sig-abc", + createMockAgent(), + ); + + expect(result).toEqual({ + ok: false, + permanent: false, + statusCode: 429, + retryAfterHeader: "60", + }); + }); + + it("returns 429 with undefined retryAfterHeader when header is absent", async () => { + mockHttpsRequest(429); + + const result = await deliverPayload( + createTarget(), + '{"test":true}', + "sig-abc", + createMockAgent(), + ); + + expect(result).toEqual({ + ok: false, + permanent: false, + statusCode: 429, + retryAfterHeader: undefined, + }); + }); + + it("returns ok: false, permanent: false on TCP error", async () => { + mockHttpsRequestError("ECONNREFUSED"); + + const result = await deliverPayload( + createTarget(), + '{"test":true}', + "sig-abc", + createMockAgent(), + ); + + expect(result).toEqual({ ok: false, permanent: false, statusCode: 0 }); + }); + + it("uses port 443 when URL has no explicit port", async () => { + mockHttpsRequest(200); + const target = createTarget(); + target.invocationEndpoint = "https://webhook.example.invalid/callback"; + + const result = await deliverPayload( + target, + '{"test":true}', + "sig-abc", + createMockAgent(), + ); + + expect(result).toEqual({ ok: true }); + const callOpts = (https.request as jest.Mock).mock.calls[0][0]; + expect(callOpts.port).toBe(443); + }); +}); diff --git a/lambdas/https-client-lambda/src/__tests__/index.test.ts b/lambdas/https-client-lambda/src/__tests__/index.test.ts new file mode 100644 index 00000000..53394149 --- /dev/null +++ b/lambdas/https-client-lambda/src/__tests__/index.test.ts @@ -0,0 +1,36 @@ +import { handler } from "index"; +import { processRecords } from "handler"; + +jest.mock("handler", () => ({ + processRecords: jest.fn().mockResolvedValue([]), +})); + +describe("handler", () => { + it("returns batchItemFailures from processRecords", async () => { + const event = { + Records: [ + { + messageId: "msg-1", + receiptHandle: "r-1", + body: "{}", + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: "0", + SenderId: "sender", + ApproximateFirstReceiveTimestamp: "0", + }, + messageAttributes: {}, + md5OfBody: "abc", + eventSource: "aws:sqs", + eventSourceARN: "arn:aws:sqs:eu-west-2:123:queue", + awsRegion: "eu-west-2", + }, + ], + }; + + const result = await handler(event); + + expect(result).toEqual({ batchItemFailures: [] }); + expect(processRecords).toHaveBeenCalledWith(event.Records); + }); +}); diff --git a/lambdas/https-client-lambda/src/__tests__/payload-signer.test.ts b/lambdas/https-client-lambda/src/__tests__/payload-signer.test.ts new file mode 100644 index 00000000..d55da566 --- /dev/null +++ b/lambdas/https-client-lambda/src/__tests__/payload-signer.test.ts @@ -0,0 +1,39 @@ +import { createHmac } from "node:crypto"; +import { signPayload } from "services/payload-signer"; + +const makePayload = () => + ({ + data: [ + { type: "MessageStatus", attributes: { messageStatus: "delivered" } }, + ], + }) as Parameters[2]; + +describe("signPayload", () => { + it("produces correct HMAC-SHA256 output for a known input", () => { + const payload = makePayload(); + // eslint-disable-next-line sonarjs/hardcoded-secret-signatures -- test fixture, not a real secret + const expected = createHmac("sha256", "app-1.key-1") + .update(JSON.stringify(payload)) + .digest("hex"); + + expect(signPayload("app-1", "key-1", payload)).toBe(expected); + }); + + it("produces different signatures for different appId/apiKey combinations", () => { + const payload = makePayload(); + + const sig1 = signPayload("app-1", "key-1", payload); + const sig2 = signPayload("app-2", "key-2", payload); + + expect(sig1).not.toBe(sig2); + }); + + it("produces the same signature for the same inputs", () => { + const payload = makePayload(); + + const sig1 = signPayload("app-1", "key-1", payload); + const sig2 = signPayload("app-1", "key-1", payload); + + expect(sig1).toBe(sig2); + }); +}); diff --git a/lambdas/https-client-lambda/src/__tests__/retry-policy.test.ts b/lambdas/https-client-lambda/src/__tests__/retry-policy.test.ts new file mode 100644 index 00000000..c0be2695 --- /dev/null +++ b/lambdas/https-client-lambda/src/__tests__/retry-policy.test.ts @@ -0,0 +1,84 @@ +import { + exceedsSqsMaxVisibility, + isWindowExhausted, + jitteredBackoffSeconds, + parseRetryAfter, +} from "services/delivery/retry-policy"; + +describe("jitteredBackoffSeconds", () => { + it("produces value in [0, 5) at receiveCount=1", () => { + for (let i = 0; i < 100; i++) { + const val = jitteredBackoffSeconds(1); + expect(val).toBeGreaterThanOrEqual(0); + expect(val).toBeLessThan(5); + } + }); + + it("produces value in [0, 300) at receiveCount=10 (cap)", () => { + for (let i = 0; i < 100; i++) { + const val = jitteredBackoffSeconds(10); + expect(val).toBeGreaterThanOrEqual(0); + expect(val).toBeLessThan(300); + } + }); + + it("respects cap at very high receiveCount", () => { + for (let i = 0; i < 50; i++) { + const val = jitteredBackoffSeconds(100); + expect(val).toBeLessThan(300); + } + }); +}); + +describe("parseRetryAfter", () => { + it("parses integer string", () => { + expect(parseRetryAfter("120")).toBe(120); + }); + + it("returns 0 for negative values", () => { + expect(parseRetryAfter("-5")).toBe(0); + }); + + it("parses HTTP date string", () => { + const futureDate = new Date(Date.now() + 60_000); + const result = parseRetryAfter(futureDate.toUTCString()); + expect(result).toBeGreaterThanOrEqual(58); + expect(result).toBeLessThanOrEqual(61); + }); + + it("returns 0 for past HTTP date", () => { + const pastDate = new Date(Date.now() - 60_000); + expect(parseRetryAfter(pastDate.toUTCString())).toBe(0); + }); + + it("returns 0 for garbage input", () => { + expect(parseRetryAfter("not-a-date-or-number")).toBe(0); + }); +}); + +describe("isWindowExhausted", () => { + it("returns false just below limit", () => { + const firstReceived = Date.now() - 999; + expect(isWindowExhausted(firstReceived, 1000)).toBe(false); + }); + + it("returns true at limit", () => { + const firstReceived = Date.now() - 1000; + expect(isWindowExhausted(firstReceived, 1000)).toBe(true); + }); + + it("returns true beyond limit", () => { + const firstReceived = Date.now() - 2000; + expect(isWindowExhausted(firstReceived, 1000)).toBe(true); + }); +}); + +describe("exceedsSqsMaxVisibility", () => { + it("returns false at 43200", () => { + expect(exceedsSqsMaxVisibility(43_200)).toBe(false); + }); + + it("returns true at 43201", () => { + expect(exceedsSqsMaxVisibility(43_201)).toBe(true); + }); +}); diff --git a/lambdas/https-client-lambda/src/__tests__/sqs-visibility.test.ts b/lambdas/https-client-lambda/src/__tests__/sqs-visibility.test.ts new file mode 100644 index 00000000..9e0d9e54 --- /dev/null +++ b/lambdas/https-client-lambda/src/__tests__/sqs-visibility.test.ts @@ -0,0 +1,71 @@ +import { ChangeMessageVisibilityCommand } from "@aws-sdk/client-sqs"; + +import { changeVisibility } from "services/sqs-visibility"; + +const mockSend = jest.fn(); +jest.mock("@aws-sdk/client-sqs", () => { + const actual = jest.requireActual("@aws-sdk/client-sqs"); + return { + ...actual, + SQSClient: jest.fn().mockImplementation(() => ({ + send: (...args: unknown[]) => mockSend(...args), + })), + }; +}); + +process.env.QUEUE_URL = "https://sqs.eu-west-2.invalid/123456789/test-queue"; + +describe("changeVisibility", () => { + beforeEach(() => { + mockSend.mockReset(); + }); + + it("sends ChangeMessageVisibilityCommand with correct params", async () => { + mockSend.mockResolvedValue({}); + + await changeVisibility("receipt-handle-1", 30); + + expect(mockSend).toHaveBeenCalledTimes(1); + const command = mockSend.mock.calls[0][0]; + expect(command).toBeInstanceOf(ChangeMessageVisibilityCommand); + expect(command.input).toEqual({ + QueueUrl: "https://sqs.eu-west-2.invalid/123456789/test-queue", + ReceiptHandle: "receipt-handle-1", + VisibilityTimeout: 30, + }); + }); + + it("floors fractional visibility timeout", async () => { + mockSend.mockResolvedValue({}); + + await changeVisibility("receipt-handle-1", 30.7); + + const command = mockSend.mock.calls[0][0]; + expect(command.input.VisibilityTimeout).toBe(30); + }); + + it("surfaces SDK errors", async () => { + mockSend.mockRejectedValue(new Error("SQS error")); + + await expect(changeVisibility("receipt-handle-1", 30)).rejects.toThrow( + "SQS error", + ); + }); + + it("throws when QUEUE_URL is not set", async () => { + let changeFn: typeof changeVisibility; + const saved = process.env.QUEUE_URL; + delete process.env.QUEUE_URL; + + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- jest.isolateModules requires synchronous require + changeFn = require("services/sqs-visibility").changeVisibility; + }); + + await expect(changeFn!("receipt-handle-1", 30)).rejects.toThrow( + "QUEUE_URL is required", + ); + + process.env.QUEUE_URL = saved; + }); +}); diff --git a/lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts b/lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts new file mode 100644 index 00000000..06485ae9 --- /dev/null +++ b/lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts @@ -0,0 +1,117 @@ +import { GetParameterCommand } from "@aws-sdk/client-ssm"; + +import { getApplicationId, resetCache } from "services/ssm-applications-map"; + +const mockSend = jest.fn(); +jest.mock("@aws-sdk/client-ssm", () => { + const actual = jest.requireActual("@aws-sdk/client-ssm"); + return { + ...actual, + SSMClient: jest.fn().mockImplementation(() => ({ + send: (...args: unknown[]) => mockSend(...args), + })), + }; +}); + +jest.mock("services/logger", () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +process.env.APPLICATIONS_MAP_PARAMETER_NAME = "/test/applications-map"; + +describe("getApplicationId", () => { + beforeEach(() => { + mockSend.mockReset(); + resetCache(); + }); + + it("returns correct applicationId for a known clientId", async () => { + mockSend.mockResolvedValue({ + Parameter: { + Value: JSON.stringify({ + "client-1": "app-id-1", + "client-2": "app-id-2", + }), + }, + }); + + const result = await getApplicationId("client-1"); + + expect(result).toBe("app-id-1"); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend.mock.calls[0][0]).toBeInstanceOf(GetParameterCommand); + }); + + it("throws for unknown clientId", async () => { + mockSend.mockResolvedValue({ + Parameter: { + Value: JSON.stringify({ "client-1": "app-id-1" }), + }, + }); + + await expect(getApplicationId("unknown")).rejects.toThrow( + "No applicationId found for clientId 'unknown' in SSM map", + ); + }); + + it("surfaces SSM SDK errors", async () => { + mockSend.mockRejectedValue(new Error("SSM unavailable")); + + await expect(getApplicationId("client-1")).rejects.toThrow( + "SSM unavailable", + ); + }); + + it("throws when APPLICATIONS_MAP_PARAMETER_NAME is not set", async () => { + let getFn: typeof getApplicationId; + const saved = process.env.APPLICATIONS_MAP_PARAMETER_NAME; + delete process.env.APPLICATIONS_MAP_PARAMETER_NAME; + + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- jest.isolateModules requires synchronous require + getFn = require("services/ssm-applications-map").getApplicationId; + }); + + await expect(getFn!("client-1")).rejects.toThrow( + "APPLICATIONS_MAP_PARAMETER_NAME is required", + ); + + process.env.APPLICATIONS_MAP_PARAMETER_NAME = saved; + }); + + it("throws when SSM parameter value is empty", async () => { + mockSend.mockResolvedValue({ Parameter: { Value: undefined } }); + + await expect(getApplicationId("client-1")).rejects.toThrow( + "not found or has no value", + ); + }); + + it("throws when SSM parameter contains invalid JSON", async () => { + mockSend.mockResolvedValue({ + Parameter: { Value: "not-json" }, + }); + + await expect(getApplicationId("client-1")).rejects.toThrow( + "contains invalid JSON", + ); + }); + + it("caches the applications map between calls", async () => { + mockSend.mockResolvedValue({ + Parameter: { + Value: JSON.stringify({ "client-1": "app-id-1" }), + }, + }); + + await getApplicationId("client-1"); + await getApplicationId("client-1"); + + expect(mockSend).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lambdas/https-client-lambda/src/__tests__/tls-agent-factory.test.ts b/lambdas/https-client-lambda/src/__tests__/tls-agent-factory.test.ts new file mode 100644 index 00000000..231b9cb2 --- /dev/null +++ b/lambdas/https-client-lambda/src/__tests__/tls-agent-factory.test.ts @@ -0,0 +1,338 @@ +import type { CallbackTarget } from "@nhs-notify-client-callbacks/models"; + +const mockS3Send = jest.fn(); +jest.mock("@aws-sdk/client-s3", () => { + const actual = jest.requireActual("@aws-sdk/client-s3"); + return { + ...actual, + S3Client: jest.fn().mockImplementation(() => ({ send: mockS3Send })), + }; +}); + +const mockSecretsManagerSend = jest.fn(); +jest.mock("@aws-sdk/client-secrets-manager", () => { + const actual = jest.requireActual("@aws-sdk/client-secrets-manager"); + return { + ...actual, + SecretsManagerClient: jest + .fn() + .mockImplementation(() => ({ send: mockSecretsManagerSend })), + }; +}); + +jest.mock("services/logger", () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +const mockValidTo = new Date(Date.now() + 365 * 86_400_000).toISOString(); + +jest.mock("node:crypto", () => { + const actual = jest.requireActual("node:crypto"); + return { + ...actual, + X509Certificate: class MockX509Certificate { + validTo = mockValidTo; + + publicKey = { + export: () => Buffer.from("mock-spki-der"), + }; + }, + }; +}); + +const TEST_KEY = + "-----BEGIN PRIVATE KEY-----\nfake-key\n-----END PRIVATE KEY-----"; +const TEST_CERT = + "-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----"; +const COMBINED_PEM = `${TEST_KEY}\n${TEST_CERT}`; + +const createTarget = ( + overrides: Partial = {}, +): CallbackTarget => ({ + targetId: "target-1", + type: "API", + invocationEndpoint: "https://webhook.example.invalid", + invocationMethod: "POST", + invocationRateLimit: 10, + apiKey: { headerName: "x-api-key", headerValue: "secret" }, + mtls: { enabled: false }, + certPinning: { enabled: false }, + ...overrides, +}); + +const mockS3PemResponse = (pem: string) => { + mockS3Send.mockResolvedValue({ + Body: { transformToString: jest.fn().mockResolvedValue(pem) }, + }); +}; + +describe("tls-agent-factory", () => { + let buildAgent: typeof import("services/delivery/tls-agent-factory").buildAgent; + let resetCache: typeof import("services/delivery/tls-agent-factory").resetCache; + + beforeEach(async () => { + jest.resetModules(); + + delete process.env.MTLS_CERT_SECRET_ARN; + process.env.MTLS_TEST_CERT_S3_BUCKET = "test-certs-bucket"; + process.env.MTLS_TEST_CERT_S3_KEY = "client.pem"; + delete process.env.MTLS_TEST_CA_S3_KEY; + process.env.CERT_EXPIRY_THRESHOLD_MS = "86400000"; + + // @ts-expect-error -- modulePaths resolves at runtime + const mod = await import("services/delivery/tls-agent-factory"); + buildAgent = mod.buildAgent; + resetCache = mod.resetCache; + + mockS3Send.mockReset(); + mockSecretsManagerSend.mockReset(); + }); + + it("builds agent with key and cert when mtls is enabled", async () => { + mockS3PemResponse(COMBINED_PEM); + const agent = await buildAgent(createTarget({ mtls: { enabled: true } })); + + expect(agent).toBeDefined(); + expect(agent.options.keepAlive).toBe(false); + }); + + it("builds agent without key and cert when mtls is disabled", async () => { + mockS3PemResponse(COMBINED_PEM); + const agent = await buildAgent(createTarget({ mtls: { enabled: false } })); + + expect(agent).toBeDefined(); + }); + + it("loads test CA when MTLS_TEST_CA_S3_KEY is set", async () => { + process.env.MTLS_TEST_CA_S3_KEY = "test-ca.pem"; + jest.resetModules(); + // @ts-expect-error -- modulePaths resolves at runtime + const mod = await import("services/delivery/tls-agent-factory"); + + const caPem = + "-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----"; + mockS3Send + .mockResolvedValueOnce({ + Body: { + transformToString: jest.fn().mockResolvedValue(COMBINED_PEM), + }, + }) + .mockResolvedValueOnce({ + Body: { transformToString: jest.fn().mockResolvedValue(caPem) }, + }); + + const agent = await mod.buildAgent( + createTarget({ mtls: { enabled: true } }), + ); + + expect(agent).toBeDefined(); + expect(mockS3Send).toHaveBeenCalledTimes(2); + }); + + it("loads cert from S3 in non-production", async () => { + mockS3PemResponse(COMBINED_PEM); + await buildAgent(createTarget({ mtls: { enabled: true } })); + + expect(mockS3Send).toHaveBeenCalledTimes(1); + expect(mockSecretsManagerSend).not.toHaveBeenCalled(); + }); + + it("loads cert from SecretsManager in production", async () => { + process.env.MTLS_CERT_SECRET_ARN = + "arn:aws:secretsmanager:eu-west-2:123:secret:mtls-cert"; + jest.resetModules(); + // @ts-expect-error -- modulePaths resolves at runtime + const mod = await import("services/delivery/tls-agent-factory"); + + mockSecretsManagerSend.mockResolvedValue({ + SecretString: JSON.stringify({ key: TEST_KEY, cert: TEST_CERT }), + }); + + const agent = await mod.buildAgent( + createTarget({ mtls: { enabled: true } }), + ); + + expect(agent).toBeDefined(); + expect(mockSecretsManagerSend).toHaveBeenCalledTimes(1); + expect(mockS3Send).not.toHaveBeenCalled(); + }); + + it("caches cert material on subsequent calls", async () => { + mockS3PemResponse(COMBINED_PEM); + const target = createTarget({ mtls: { enabled: false } }); + + await buildAgent(target); + await buildAgent(target); + + expect(mockS3Send).toHaveBeenCalledTimes(1); + }); + + it("exports PERMANENT_TLS_ERROR_CODES set", async () => { + // @ts-expect-error -- modulePaths resolves at runtime + const mod = await import("services/delivery/tls-agent-factory"); + + expect(mod.PERMANENT_TLS_ERROR_CODES).toBeInstanceOf(Set); + expect(mod.PERMANENT_TLS_ERROR_CODES.has("CERT_HAS_EXPIRED")).toBe(true); + }); + + it("resets cached material via resetCache", async () => { + mockS3PemResponse(COMBINED_PEM); + const target = createTarget({ mtls: { enabled: false } }); + + await buildAgent(target); + resetCache(); + await buildAgent(target); + + expect(mockS3Send).toHaveBeenCalledTimes(2); + }); + + it("throws when SecretsManager returns empty SecretString", async () => { + process.env.MTLS_CERT_SECRET_ARN = + "arn:aws:secretsmanager:eu-west-2:123:secret:mtls-cert"; + jest.resetModules(); + // @ts-expect-error -- modulePaths resolves at runtime + const mod = await import("services/delivery/tls-agent-factory"); + + mockSecretsManagerSend.mockResolvedValue({ SecretString: undefined }); + + await expect( + mod.buildAgent(createTarget({ mtls: { enabled: true } })), + ).rejects.toThrow("mTLS cert secret has no value"); + }); + + it("throws when S3 env vars are missing in non-production", async () => { + delete process.env.MTLS_TEST_CERT_S3_BUCKET; + delete process.env.MTLS_TEST_CERT_S3_KEY; + jest.resetModules(); + // @ts-expect-error -- modulePaths resolves at runtime + const mod = await import("services/delivery/tls-agent-factory"); + + await expect( + mod.buildAgent(createTarget({ mtls: { enabled: true } })), + ).rejects.toThrow( + "MTLS_TEST_CERT_S3_BUCKET and MTLS_TEST_CERT_S3_KEY are required", + ); + }); + + it("throws when S3 object body is empty", async () => { + mockS3Send.mockResolvedValue({ Body: undefined }); + + await expect( + buildAgent(createTarget({ mtls: { enabled: true } })), + ).rejects.toThrow("has no body"); + }); + + it("builds agent with checkServerIdentity when certPinning is enabled", async () => { + mockS3PemResponse(COMBINED_PEM); + const target = createTarget({ + mtls: { enabled: true }, + certPinning: { enabled: true, spkiHash: "abc123" }, + }); + + const agent = await buildAgent(target); + + expect(agent).toBeDefined(); + expect(agent.options.checkServerIdentity).toBeDefined(); + }); + + it("checkServerIdentity returns error when SPKI hash does not match", async () => { + mockS3PemResponse(COMBINED_PEM); + const target = createTarget({ + mtls: { enabled: true }, + certPinning: { enabled: true, spkiHash: "expected-hash" }, + }); + + const agent = await buildAgent(target); + const checkFn = agent.options.checkServerIdentity as ( + hostname: string, + cert: { raw: Buffer; subject: { CN: string } }, + ) => Error | undefined; + + const mockPeerCert = { + raw: Buffer.from("mock-cert-der"), + subject: { CN: "webhook.example.invalid" }, + subjectaltname: "DNS:webhook.example.invalid", + }; + + const result = checkFn("webhook.example.invalid", mockPeerCert); + + expect(result).toBeInstanceOf(Error); + expect(result!.message).toContain("Certificate pinning failed"); + expect((result as NodeJS.ErrnoException).code).toBe( + "ERR_CERT_PINNING_FAILED", + ); + }); + + it("checkServerIdentity returns undefined when SPKI hash matches", async () => { + const { createHash } = jest.requireActual("node:crypto"); + const expectedHash = createHash("sha256") + .update(Buffer.from("mock-spki-der")) + .digest("base64"); + + mockS3PemResponse(COMBINED_PEM); + const target = createTarget({ + mtls: { enabled: true }, + certPinning: { enabled: true, spkiHash: expectedHash }, + }); + + const agent = await buildAgent(target); + const checkFn = agent.options.checkServerIdentity as ( + hostname: string, + cert: { raw: Buffer; subject: { CN: string } }, + ) => Error | undefined; + + const mockPeerCert = { + raw: Buffer.from("mock-cert-der"), + subject: { CN: "webhook.example.invalid" }, + subjectaltname: "DNS:webhook.example.invalid", + }; + + const result = checkFn("webhook.example.invalid", mockPeerCert); + + expect(result).toBeUndefined(); + }); + + it("checkServerIdentity returns default error when hostname does not match", async () => { + mockS3PemResponse(COMBINED_PEM); + const target = createTarget({ + mtls: { enabled: true }, + certPinning: { enabled: true, spkiHash: "abc" }, + }); + + const agent = await buildAgent(target); + const checkFn = agent.options.checkServerIdentity as ( + hostname: string, + cert: { raw: Buffer; subject: { CN: string } }, + ) => Error | undefined; + + const mockPeerCert = { + raw: Buffer.from("mock-cert-der"), + subject: { CN: "other.example.invalid" }, + subjectaltname: "DNS:other.example.invalid", + }; + + const result = checkFn("webhook.example.invalid", mockPeerCert); + + expect(result).toBeDefined(); + expect(result!.message).toContain("does not match"); + }); + + it("throws when MTLS_CERT_SECRET_ARN is falsy in loadFromSecretsManager path", async () => { + process.env.MTLS_CERT_SECRET_ARN = ""; + jest.resetModules(); + // @ts-expect-error -- modulePaths resolves at runtime + const mod = await import("services/delivery/tls-agent-factory"); + + mockS3PemResponse(COMBINED_PEM); + + const agent = await mod.buildAgent( + createTarget({ mtls: { enabled: false } }), + ); + expect(agent).toBeDefined(); + }); +}); diff --git a/lambdas/https-client-lambda/src/handler.ts b/lambdas/https-client-lambda/src/handler.ts new file mode 100644 index 00000000..2fd807d0 --- /dev/null +++ b/lambdas/https-client-lambda/src/handler.ts @@ -0,0 +1,213 @@ +import type { SQSBatchItemFailure, SQSRecord } from "aws-lambda"; +import type { ClientCallbackPayload } from "@nhs-notify-client-callbacks/models"; +import { logger } from "services/logger"; +import { loadTargetConfig } from "services/config-loader"; +import { getApplicationId } from "services/ssm-applications-map"; +import { signPayload } from "services/payload-signer"; +import { buildAgent } from "services/delivery/tls-agent-factory"; +import { deliverPayload } from "services/delivery/https-client"; +import { sendToDlq } from "services/dlq-sender"; +import { changeVisibility } from "services/sqs-visibility"; +import { + exceedsSqsMaxVisibility, + isWindowExhausted, + jitteredBackoffSeconds, + parseRetryAfter, +} from "services/delivery/retry-policy"; +import { + type EndpointGateConfig, + admit, + getRedisClient, + recordResult, +} from "services/endpoint-gate"; +import { + emitCircuitBreakerOpen, + emitDeliveryAttempt, + emitDeliveryFailure, + emitDeliveryPermanentFailure, + emitDeliverySuccess, + flushMetrics, +} from "services/delivery-metrics"; + +const DEFAULT_MAX_RETRY_DURATION_MS = 3_600_000; + +const gateConfig: EndpointGateConfig = { + burstCapacity: Number(process.env.TOKEN_BUCKET_BURST_CAPACITY ?? "10"), + cbProbeIntervalMs: Number(process.env.CB_PROBE_INTERVAL_MS ?? "60000"), + decayPeriodMs: Number(process.env.CB_DECAY_PERIOD_MS ?? "300000"), + cbWindowPeriodMs: Number(process.env.CB_WINDOW_PERIOD_MS ?? "60000"), + cbErrorThreshold: Number(process.env.CB_ERROR_THRESHOLD ?? "0.5"), + cbMinAttempts: Number(process.env.CB_MIN_ATTEMPTS ?? "10"), + cbCooldownMs: Number(process.env.CB_COOLDOWN_MS ?? "60000"), +}; + +type CallbackDeliveryMessage = { + payload: ClientCallbackPayload; + subscriptions: string[]; + targetId: string; +}; + +async function handleRateLimited( + record: SQSRecord, + clientId: string, + targetId: string, + retryAfterHeader: string | undefined, + receiveCount: number, +): Promise { + const retryAfterSeconds = retryAfterHeader + ? parseRetryAfter(retryAfterHeader) + : 0; + + if (exceedsSqsMaxVisibility(retryAfterSeconds)) { + logger.warn("429 Retry-After exceeds SQS max — sending to DLQ", { + clientId, + targetId, + retryAfterSeconds, + }); + await sendToDlq(record.body); + return; + } + + const delaySec = + retryAfterSeconds > 0 + ? retryAfterSeconds + : jitteredBackoffSeconds(receiveCount); + + logger.warn("Rate limited (429) — requeuing", { + clientId, + targetId, + delaySec, + }); + await changeVisibility(record.receiptHandle, delaySec); + throw new Error("Rate limited — requeue"); +} + +async function processRecord(record: SQSRecord): Promise { + const { CLIENT_ID } = process.env; + if (!CLIENT_ID) { + throw new Error("CLIENT_ID is required"); + } + + const message: CallbackDeliveryMessage = JSON.parse(record.body); + const { payload, targetId } = message; + + logger.info("Processing delivery", { clientId: CLIENT_ID, targetId }); + + const target = await loadTargetConfig(CLIENT_ID, targetId); + const maxRetryDurationMs = + (target.delivery?.maxRetryDurationSeconds ?? + DEFAULT_MAX_RETRY_DURATION_MS / 1000) * 1000; + + const firstReceivedMs = Number( + record.attributes.ApproximateFirstReceiveTimestamp, + ); + const receiveCount = Number(record.attributes.ApproximateReceiveCount); + + if (isWindowExhausted(firstReceivedMs, maxRetryDurationMs)) { + logger.warn("Retry window exhausted — sending to DLQ", { + clientId: CLIENT_ID, + targetId, + }); + await sendToDlq(record.body); + return; + } + + const applicationId = await getApplicationId(CLIENT_ID); + + const redis = await getRedisClient(); + const cbEnabled = target.delivery?.circuitBreaker?.enabled ?? false; + const gateResult = await admit( + redis, + targetId, + target.invocationRateLimit, + cbEnabled, + gateConfig, + ); + + if (!gateResult.allowed) { + const delaySec = Math.ceil(gateResult.retryAfterMs / 1000); + logger.warn(`Admission denied: ${gateResult.reason} — requeuing`, { + clientId: CLIENT_ID, + targetId, + reason: gateResult.reason, + delaySec, + }); + await changeVisibility(record.receiptHandle, delaySec); + throw new Error(`Admission denied: ${gateResult.reason}`); + } + + const agent = await buildAgent(target); + const signature = signPayload( + applicationId, + target.apiKey.headerValue, + payload, + ); + const payloadJson = JSON.stringify(payload); + + emitDeliveryAttempt(targetId); + const result = await deliverPayload(target, payloadJson, signature, agent); + + if (result.ok) { + if (cbEnabled) { + await recordResult(redis, targetId, true, gateConfig); + } + emitDeliverySuccess(targetId); + logger.info("Delivery succeeded", { clientId: CLIENT_ID, targetId }); + return; + } + + if (result.permanent) { + emitDeliveryPermanentFailure(targetId); + logger.warn("Permanent delivery failure — sending to DLQ", { + clientId: CLIENT_ID, + targetId, + }); + await sendToDlq(record.body); + return; + } + + if ("retryAfterHeader" in result) { + await handleRateLimited( + record, + CLIENT_ID, + targetId, + result.retryAfterHeader, + receiveCount, + ); + return; + } + + const backoffSec = jitteredBackoffSeconds(receiveCount); + if (cbEnabled) { + const cbOutcome = await recordResult(redis, targetId, false, gateConfig); + if (!cbOutcome.ok) { + emitCircuitBreakerOpen(targetId); + } + } + emitDeliveryFailure(targetId); + logger.warn("Transient delivery failure — requeuing", { + clientId: CLIENT_ID, + targetId, + statusCode: result.statusCode, + backoffSec, + }); + await changeVisibility(record.receiptHandle, backoffSec); + throw new Error(`Transient failure: ${result.statusCode}`); +} + +export async function processRecords( + records: SQSRecord[], +): Promise { + const failures: SQSBatchItemFailure[] = []; + + for (const record of records) { + try { + await processRecord(record); + } catch { + failures.push({ itemIdentifier: record.messageId }); + } + } + + await flushMetrics(); + return failures; +} diff --git a/lambdas/https-client-lambda/src/index.ts b/lambdas/https-client-lambda/src/index.ts new file mode 100644 index 00000000..d53608ff --- /dev/null +++ b/lambdas/https-client-lambda/src/index.ts @@ -0,0 +1,7 @@ +import type { SQSBatchResponse, SQSEvent } from "aws-lambda"; +import { processRecords } from "handler"; + +export async function handler(event: SQSEvent): Promise { + const batchItemFailures = await processRecords(event.Records); + return { batchItemFailures }; +} diff --git a/lambdas/https-client-lambda/src/lua.d.ts b/lambdas/https-client-lambda/src/lua.d.ts new file mode 100644 index 00000000..8fe49f84 --- /dev/null +++ b/lambdas/https-client-lambda/src/lua.d.ts @@ -0,0 +1,4 @@ +declare module "*.lua" { + const content: string; + export default content; +} diff --git a/lambdas/https-client-lambda/src/services/admit.lua b/lambdas/https-client-lambda/src/services/admit.lua new file mode 100644 index 00000000..8fdf2b15 --- /dev/null +++ b/lambdas/https-client-lambda/src/services/admit.lua @@ -0,0 +1,95 @@ +-- admit.lua +-- Atomic token-bucket rate limiter + circuit-breaker admission check. +-- KEYS[1] = rl:{targetId} (rate limiter hash) +-- KEYS[2] = cb:{targetId} (circuit breaker hash) +-- ARGV[1] = now (epoch ms) +-- ARGV[2] = refillPerSec (tokens/sec from target config) +-- ARGV[3] = capacity (burst capacity) +-- ARGV[4] = cbProbeIntervalMs +-- ARGV[5] = cbEnabled ("1" or "0") +-- ARGV[6] = decayPeriodMs + +local rl_key = KEYS[1] +local cb_key = KEYS[2] +local now = tonumber(ARGV[1]) +local refillPerSec = tonumber(ARGV[2]) +local capacity = tonumber(ARGV[3]) +local cbProbeIntervalMs = tonumber(ARGV[4]) +local cbEnabled = ARGV[5] == "1" +local decayPeriodMs = tonumber(ARGV[6]) + +-- Load circuit breaker state +local opened_until_ms = tonumber(redis.call("HGET", cb_key, "opened_until_ms") or "0") or 0 +local last_probe_ms = tonumber(redis.call("HGET", cb_key, "last_probe_ms") or "0") or 0 + +-- Circuit breaker evaluation (only when enabled) +if cbEnabled and opened_until_ms > 0 and now < opened_until_ms then + -- Circuit is open — check for probe slot + if cbProbeIntervalMs > 0 and (now - last_probe_ms) >= cbProbeIntervalMs then + redis.call("HSET", cb_key, "last_probe_ms", tostring(now)) + return cjson.encode({ + allowed = true, + probe = true, + effectiveRate = 0, + }) + end + -- No probe slot available + local retryAfterMs = opened_until_ms - now + return cjson.encode({ + allowed = false, + reason = "circuit_open", + retryAfterMs = retryAfterMs, + effectiveRate = 0, + }) +end + +-- Compute effective rate (with decay scaling if applicable) +local effectiveRate = refillPerSec + +if cbEnabled and opened_until_ms > 0 and now >= opened_until_ms and decayPeriodMs > 0 then + local elapsed_since_close = now - opened_until_ms + if elapsed_since_close < decayPeriodMs then + effectiveRate = refillPerSec * (elapsed_since_close / decayPeriodMs) + if effectiveRate < 0.001 then + effectiveRate = 0.001 + end + end +end + +-- Load rate limiter state +local tokens = tonumber(redis.call("HGET", rl_key, "tokens") or "") or capacity +local last_refill_ms = tonumber(redis.call("HGET", rl_key, "last_refill_ms") or "") or now + +-- Refill tokens +local elapsed_ms = now - last_refill_ms +if elapsed_ms > 0 then + tokens = math.min(capacity, tokens + elapsed_ms * effectiveRate / 1000) +end + +-- Check rate limit +if tokens < 1 then + -- Compute retry-after based on effective rate + local retryAfterMs = 0 + if effectiveRate > 0 then + retryAfterMs = math.ceil((1 - tokens) / effectiveRate * 1000) + else + retryAfterMs = 1000 + end + return cjson.encode({ + allowed = false, + reason = "rate_limited", + retryAfterMs = retryAfterMs, + effectiveRate = effectiveRate, + }) +end + +-- Deduct token and update state +tokens = tokens - 1 +redis.call("HSET", rl_key, "tokens", tostring(tokens)) +redis.call("HSET", rl_key, "last_refill_ms", tostring(now)) + +return cjson.encode({ + allowed = true, + probe = false, + effectiveRate = effectiveRate, +}) diff --git a/lambdas/https-client-lambda/src/services/config-loader.ts b/lambdas/https-client-lambda/src/services/config-loader.ts new file mode 100644 index 00000000..bd17eac9 --- /dev/null +++ b/lambdas/https-client-lambda/src/services/config-loader.ts @@ -0,0 +1,71 @@ +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { + type CallbackTarget, + parseClientSubscriptionConfiguration, +} from "@nhs-notify-client-callbacks/models"; +import { ConfigCache } from "@nhs-notify-client-callbacks/config-cache"; +import { logger } from "services/logger"; + +const s3Client = new S3Client({}); +let cache: ConfigCache | undefined; + +function getCache(): ConfigCache { + if (!cache) { + const ttl = Number(process.env.CONFIG_CACHE_TTL_MS) || 300_000; + cache = new ConfigCache(ttl); + } + return cache; +} + +export function resetCache(): void { + cache = undefined; +} + +export async function loadTargetConfig( + clientId: string, + targetId: string, +): Promise { + let clientConfig = getCache().get(clientId); + + if (!clientConfig) { + const { CLIENT_CONFIG_BUCKET } = process.env; + if (!CLIENT_CONFIG_BUCKET) { + throw new Error("CLIENT_CONFIG_BUCKET is required"); + } + + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: CLIENT_CONFIG_BUCKET, + Key: `clients/${clientId}.json`, + }), + ); + + if (!response.Body) { + throw new Error(`S3 response body was empty for client '${clientId}'`); + } + + const raw = await response.Body.transformToString(); + const parsed = JSON.parse(raw) as unknown; + const result = parseClientSubscriptionConfiguration(parsed); + + if (!result.success) { + throw new Error( + `Invalid client config for '${clientId}': ${result.error.message}`, + ); + } + + clientConfig = result.data; + getCache().set(clientId, clientConfig); + logger.info("Client config loaded from S3", { clientId }); + } + + const target = clientConfig.targets.find((t) => t.targetId === targetId); + + if (!target) { + throw new Error( + `Target '${targetId}' not found in config for client '${clientId}'`, + ); + } + + return target; +} diff --git a/lambdas/https-client-lambda/src/services/delivery-metrics.ts b/lambdas/https-client-lambda/src/services/delivery-metrics.ts new file mode 100644 index 00000000..4730ecfd --- /dev/null +++ b/lambdas/https-client-lambda/src/services/delivery-metrics.ts @@ -0,0 +1,66 @@ +import { Unit, createMetricsLogger } from "aws-embedded-metrics"; +import type { MetricsLogger } from "aws-embedded-metrics"; + +let metricsInstance: MetricsLogger | undefined; + +function getMetrics(): MetricsLogger { + if (metricsInstance) { + return metricsInstance; + } + + const namespace = process.env.METRICS_NAMESPACE; + const environment = process.env.ENVIRONMENT; + + if (!namespace) { + throw new Error("METRICS_NAMESPACE environment variable is not set"); + } + if (!environment) { + throw new Error("ENVIRONMENT environment variable is not set"); + } + + metricsInstance = createMetricsLogger(); + metricsInstance.setNamespace(namespace); + metricsInstance.setDimensions({ Environment: environment }); + + return metricsInstance; +} + +export function emitDeliveryAttempt(targetId: string): void { + const metrics = getMetrics(); + metrics.setProperty("targetId", targetId); + metrics.putMetric("DeliveryAttempt", 1, Unit.Count); +} + +export function emitDeliverySuccess(targetId: string): void { + const metrics = getMetrics(); + metrics.setProperty("targetId", targetId); + metrics.putMetric("DeliverySuccess", 1, Unit.Count); +} + +export function emitDeliveryFailure(targetId: string): void { + const metrics = getMetrics(); + metrics.setProperty("targetId", targetId); + metrics.putMetric("DeliveryFailure", 1, Unit.Count); +} + +export function emitDeliveryPermanentFailure(targetId: string): void { + const metrics = getMetrics(); + metrics.setProperty("targetId", targetId); + metrics.putMetric("DeliveryPermanentFailure", 1, Unit.Count); +} + +export function emitCircuitBreakerOpen(targetId: string): void { + const metrics = getMetrics(); + metrics.setProperty("targetId", targetId); + metrics.putMetric("CircuitBreakerOpen", 1, Unit.Count); +} + +export async function flushMetrics(): Promise { + if (metricsInstance) { + await metricsInstance.flush(); + } +} + +export function resetMetrics(): void { + metricsInstance = undefined; +} diff --git a/lambdas/https-client-lambda/src/services/delivery/https-client.ts b/lambdas/https-client-lambda/src/services/delivery/https-client.ts new file mode 100644 index 00000000..b9d6efa1 --- /dev/null +++ b/lambdas/https-client-lambda/src/services/delivery/https-client.ts @@ -0,0 +1,84 @@ +import https from "node:https"; +import type { Agent } from "node:https"; +import type { CallbackTarget } from "@nhs-notify-client-callbacks/models"; +import { PERMANENT_TLS_ERROR_CODES } from "services/delivery/tls-agent-factory"; + +export type DeliveryResult = + | { ok: true } + | { ok: false; permanent: true } + | { + ok: false; + permanent: false; + statusCode: 429; + retryAfterHeader: string | undefined; + } + | { ok: false; permanent: false; statusCode: number }; + +export function deliverPayload( + target: CallbackTarget, + signedPayloadJson: string, + signatureHeader: string, + agent: Agent, +): Promise { + return new Promise((resolve) => { + const url = new URL(target.invocationEndpoint); + + const req = https.request( + { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname + url.search, + method: "POST", + agent, + headers: { + "Content-Type": "application/json", + "X-Notify-Signature": signatureHeader, + [target.apiKey.headerName]: target.apiKey.headerValue, + }, + }, + (res) => { + res.resume(); + + const statusCode = res.statusCode ?? 0; + + if (statusCode >= 200 && statusCode < 300) { + resolve({ ok: true }); + return; + } + + if (statusCode === 429) { + const retryAfterHeader = res.headers["retry-after"]; + resolve({ + ok: false, + permanent: false, + statusCode: 429, + retryAfterHeader, + }); + return; + } + + if (statusCode >= 400 && statusCode < 500) { + resolve({ ok: false, permanent: true }); + return; + } + + resolve({ ok: false, permanent: false, statusCode }); + }, + ); + + req.on("error", (error: NodeJS.ErrnoException) => { + if ( + error.code && + (PERMANENT_TLS_ERROR_CODES.has(error.code) || + error.code === "ERR_CERT_PINNING_FAILED") + ) { + resolve({ ok: false, permanent: true }); + return; + } + + resolve({ ok: false, permanent: false, statusCode: 0 }); + }); + + req.end(signedPayloadJson); + }); +} diff --git a/lambdas/https-client-lambda/src/services/delivery/retry-policy.ts b/lambdas/https-client-lambda/src/services/delivery/retry-policy.ts new file mode 100644 index 00000000..2df24ac5 --- /dev/null +++ b/lambdas/https-client-lambda/src/services/delivery/retry-policy.ts @@ -0,0 +1,34 @@ +const BACKOFF_CAP_SECONDS = 300; +const SQS_MAX_VISIBILITY_SECONDS = 43_200; + +export function jitteredBackoffSeconds(receiveCount: number): number { + const ceiling = Math.min(5 * 2 ** (receiveCount - 1), BACKOFF_CAP_SECONDS); + // eslint-disable-next-line sonarjs/pseudo-random -- jitter for backoff, not security-sensitive + return Math.floor(Math.random() * ceiling); +} + +export function parseRetryAfter(header: string): number { + const asInt = Number(header); + + if (!Number.isNaN(asInt) && Number.isFinite(asInt)) { + return Math.max(0, Math.floor(asInt)); + } + + const date = new Date(header); + if (Number.isNaN(date.getTime())) { + return 0; + } + + return Math.max(0, Math.floor((date.getTime() - Date.now()) / 1000)); +} + +export function isWindowExhausted( + firstReceivedMs: number, + maxRetryDurationMs: number, +): boolean { + return Date.now() - firstReceivedMs >= maxRetryDurationMs; +} + +export function exceedsSqsMaxVisibility(retryAfterSeconds: number): boolean { + return retryAfterSeconds > SQS_MAX_VISIBILITY_SECONDS; +} diff --git a/lambdas/https-client-lambda/src/services/delivery/tls-agent-factory.ts b/lambdas/https-client-lambda/src/services/delivery/tls-agent-factory.ts new file mode 100644 index 00000000..0b5f9dd0 --- /dev/null +++ b/lambdas/https-client-lambda/src/services/delivery/tls-agent-factory.ts @@ -0,0 +1,193 @@ +import { Agent } from "node:https"; +import { X509Certificate, createHash } from "node:crypto"; +import { checkServerIdentity } from "node:tls"; +import type { PeerCertificate } from "node:tls"; +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { + GetSecretValueCommand, + SecretsManagerClient, +} from "@aws-sdk/client-secrets-manager"; +import type { CallbackTarget } from "@nhs-notify-client-callbacks/models"; +import { logger } from "services/logger"; + +const { MTLS_CERT_SECRET_ARN } = process.env; +const { MTLS_TEST_CERT_S3_BUCKET } = process.env; +const { MTLS_TEST_CERT_S3_KEY } = process.env; +const { MTLS_TEST_CA_S3_KEY } = process.env; +const CERT_EXPIRY_THRESHOLD_MS = + Number(process.env.CERT_EXPIRY_THRESHOLD_MS) || 86_400_000; + +const s3Client = new S3Client({}); +const secretsClient = new SecretsManagerClient({}); + +type CertMaterial = { + key: string; + cert: string; + ca?: string; + validTo: Date; +}; + +let cachedMaterial: CertMaterial | undefined; + +async function loadFromSecretsManager(): Promise<{ + key: string; + cert: string; +}> { + const response = await secretsClient.send( + new GetSecretValueCommand({ SecretId: MTLS_CERT_SECRET_ARN }), + ); + + if (!response.SecretString) { + throw new Error("mTLS cert secret has no value"); + } + + const parsed = JSON.parse(response.SecretString) as { + key: string; + cert: string; + }; + return { key: parsed.key, cert: parsed.cert }; +} + +async function loadS3Object(bucket: string, key: string): Promise { + const response = await s3Client.send( + new GetObjectCommand({ Bucket: bucket, Key: key }), + ); + + if (!response.Body) { + throw new Error(`S3 object s3://${bucket}/${key} has no body`); + } + + return response.Body.transformToString(); +} + +async function loadFromS3(): Promise<{ + key: string; + cert: string; + ca?: string; +}> { + if (!MTLS_TEST_CERT_S3_BUCKET || !MTLS_TEST_CERT_S3_KEY) { + throw new Error( + "MTLS_TEST_CERT_S3_BUCKET and MTLS_TEST_CERT_S3_KEY are required in non-production", + ); + } + + const pem = await loadS3Object( + MTLS_TEST_CERT_S3_BUCKET, + MTLS_TEST_CERT_S3_KEY, + ); + // eslint-disable-next-line sonarjs/null-dereference -- loadS3Object always returns a string + const parts = pem.split(/(?=-----BEGIN )/); + // eslint-disable-next-line sonarjs/null-dereference -- .find returns string|undefined, fallback handles it + const key = parts.find((p) => p.includes("PRIVATE KEY")) ?? ""; + // eslint-disable-next-line sonarjs/null-dereference -- as above + const cert = parts.find((p) => p.includes("CERTIFICATE")) ?? ""; + + let ca: string | undefined; + if (MTLS_TEST_CA_S3_KEY) { + ca = await loadS3Object(MTLS_TEST_CERT_S3_BUCKET, MTLS_TEST_CA_S3_KEY); + } + + return { key, cert, ca }; +} + +async function loadCertMaterial(): Promise { + const isProduction = Boolean(MTLS_CERT_SECRET_ARN); + const raw = isProduction + ? await loadFromSecretsManager() + : await loadFromS3(); + + const x509 = new X509Certificate(raw.cert); + const validTo = new Date(x509.validTo); + + logger.info("mTLS certificate loaded", { + source: isProduction ? "SecretsManager" : "S3", + validTo: validTo.toISOString(), + }); + + return { + key: raw.key, + cert: raw.cert, + ca: "ca" in raw ? (raw.ca as string | undefined) : undefined, + validTo, + }; +} + +function isExpiringSoon(material: CertMaterial): boolean { + return material.validTo.getTime() - Date.now() < CERT_EXPIRY_THRESHOLD_MS; +} + +async function getMaterial(): Promise { + if (cachedMaterial && !isExpiringSoon(cachedMaterial)) { + return cachedMaterial; + } + + cachedMaterial = await loadCertMaterial(); + return cachedMaterial; +} + +const PERMANENT_TLS_ERROR_CODES = new Set([ + "CERT_HAS_EXPIRED", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "SELF_SIGNED_CERT_IN_CHAIN", + "DEPTH_ZERO_SELF_SIGNED_CERT", + "ERR_TLS_CERT_ALTNAME_INVALID", +]); + +export { PERMANENT_TLS_ERROR_CODES }; + +export async function buildAgent(target: CallbackTarget): Promise { + const material = await getMaterial(); + + const agentOptions: Record = { + keepAlive: false, + }; + + if (target.mtls.enabled) { + agentOptions.key = material.key; + agentOptions.cert = material.cert; + } + + if (material.ca) { + agentOptions.ca = material.ca; + } + + if (target.certPinning.enabled) { + const expectedHash = target.certPinning.spkiHash; + + /* eslint-disable sonarjs/function-return-type -- checkServerIdentity requires Error|undefined return */ + agentOptions.checkServerIdentity = ( + hostname: string, + peerCert: PeerCertificate, + ) => { + const defaultResult = checkServerIdentity(hostname, peerCert); + if (defaultResult) { + return defaultResult; + } + + const rawDer = peerCert.raw; + const x509 = new X509Certificate(rawDer); + const spkiDer = x509.publicKey.export({ + type: "spki", + format: "der", + }) as Buffer; + const actualHash = createHash("sha256").update(spkiDer).digest("base64"); + + if (actualHash !== expectedHash) { + const error = new Error( + `Certificate pinning failed: expected SPKI hash '${expectedHash}', got '${actualHash}'`, + ); + (error as NodeJS.ErrnoException).code = "ERR_CERT_PINNING_FAILED"; + return error; + } + + return undefined; + }; + /* eslint-enable sonarjs/function-return-type */ + } + + return new Agent(agentOptions as ConstructorParameters[0]); +} + +export function resetCache(): void { + cachedMaterial = undefined; +} diff --git a/lambdas/https-client-lambda/src/services/dlq-sender.ts b/lambdas/https-client-lambda/src/services/dlq-sender.ts new file mode 100644 index 00000000..af61a666 --- /dev/null +++ b/lambdas/https-client-lambda/src/services/dlq-sender.ts @@ -0,0 +1,17 @@ +import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; + +const sqsClient = new SQSClient({}); + +export async function sendToDlq(messageBody: string): Promise { + const { DLQ_URL } = process.env; + if (!DLQ_URL) { + throw new Error("DLQ_URL is required"); + } + + await sqsClient.send( + new SendMessageCommand({ + QueueUrl: DLQ_URL, + MessageBody: messageBody, + }), + ); +} diff --git a/lambdas/https-client-lambda/src/services/endpoint-gate.ts b/lambdas/https-client-lambda/src/services/endpoint-gate.ts new file mode 100644 index 00000000..73721246 --- /dev/null +++ b/lambdas/https-client-lambda/src/services/endpoint-gate.ts @@ -0,0 +1,179 @@ +import { type RedisClientType, createClient } from "@redis/client"; +import { createHash } from "node:crypto"; +import { logger } from "services/logger"; +import admitLuaSrc from "services/admit.lua"; +import recordResultLuaSrc from "services/record-result.lua"; + +export type AdmitResultAllowed = { + allowed: true; + probe: boolean; + effectiveRate: number; +}; + +export type AdmitResultDenied = { + allowed: false; + reason: "circuit_open" | "rate_limited"; + retryAfterMs: number; + effectiveRate: number; +}; + +export type AdmitResult = AdmitResultAllowed | AdmitResultDenied; + +export type RecordResultOutcome = + | { ok: true; state: "closed" } + | { ok: false; state: "opened" }; + +export type EndpointGateConfig = { + burstCapacity: number; + cbProbeIntervalMs: number; + decayPeriodMs: number; + cbWindowPeriodMs: number; + cbErrorThreshold: number; + cbMinAttempts: number; + cbCooldownMs: number; +}; + +let admitSha: string | undefined; +let recordResultSha: string | undefined; + +function computeSha1(script: string): string { + // eslint-disable-next-line sonarjs/hashing -- SHA-1 required by Redis EVALSHA protocol, not a security context + return createHash("sha1").update(script).digest("hex"); +} + +export async function admit( + client: RedisClientType, + targetId: string, + refillPerSec: number, + cbEnabled: boolean, + config: EndpointGateConfig, +): Promise { + const rlKey = `rl:${targetId}`; + const cbKey = `cb:${targetId}`; + const now = Date.now().toString(); + + /* eslint-disable sonarjs/null-dereference -- refillPerSec is typed as number, cannot be null */ + const args = [ + now, + refillPerSec.toString(), + config.burstCapacity.toString(), + config.cbProbeIntervalMs.toString(), + cbEnabled ? "1" : "0", + config.decayPeriodMs.toString(), + ]; + /* eslint-enable sonarjs/null-dereference */ + + let result: string; + + if (!admitSha) { + admitSha = computeSha1(admitLuaSrc); + } + + try { + result = await client.sendCommand([ + "EVALSHA", + admitSha, + "2", + rlKey, + cbKey, + ...args, + ]); + } catch (error: unknown) { + const isNoScript = + error instanceof Error && error.message.includes("NOSCRIPT"); + if (!isNoScript) { + throw error; + } + result = await client.sendCommand([ + "EVAL", + admitLuaSrc, + "2", + rlKey, + cbKey, + ...args, + ]); + } + + return JSON.parse(result) as AdmitResult; +} + +export async function recordResult( + client: RedisClientType, + targetId: string, + success: boolean, + config: EndpointGateConfig, +): Promise { + const cbKey = `cb:${targetId}`; + const now = Date.now().toString(); + + const args = [ + now, + success ? "1" : "0", + config.cbWindowPeriodMs.toString(), + config.cbErrorThreshold.toString(), + config.cbMinAttempts.toString(), + config.cbCooldownMs.toString(), + config.decayPeriodMs.toString(), + ]; + + let result: string; + + if (!recordResultSha) { + recordResultSha = computeSha1(recordResultLuaSrc); + } + + try { + result = await client.sendCommand([ + "EVALSHA", + recordResultSha, + "1", + cbKey, + ...args, + ]); + } catch (error: unknown) { + const isNoScript = + error instanceof Error && error.message.includes("NOSCRIPT"); + if (!isNoScript) { + throw error; + } + result = await client.sendCommand([ + "EVAL", + recordResultLuaSrc, + "1", + cbKey, + ...args, + ]); + } + + return JSON.parse(result) as RecordResultOutcome; +} + +export function resetAdmitSha(): void { + admitSha = undefined; + recordResultSha = undefined; +} + +let redisClient: RedisClientType | undefined; + +export async function getRedisClient(): Promise { + if (redisClient?.isOpen) { + return redisClient; + } + + const endpoint = process.env.ELASTICACHE_ENDPOINT; + if (!endpoint) { + throw new Error("ELASTICACHE_ENDPOINT is required"); + } + + redisClient = createClient({ url: `rediss://${endpoint}:6379` }); + redisClient.on("error", (err) => { + logger.error("Redis connection error", { error: String(err) }); + }); + + await redisClient.connect(); + return redisClient; +} + +export function resetRedisClient(): void { + redisClient = undefined; +} diff --git a/lambdas/https-client-lambda/src/services/logger.ts b/lambdas/https-client-lambda/src/services/logger.ts new file mode 100644 index 00000000..5c373b25 --- /dev/null +++ b/lambdas/https-client-lambda/src/services/logger.ts @@ -0,0 +1 @@ +export * from "@nhs-notify-client-callbacks/logger"; diff --git a/lambdas/client-transform-filter-lambda/src/services/payload-signer.ts b/lambdas/https-client-lambda/src/services/payload-signer.ts similarity index 100% rename from lambdas/client-transform-filter-lambda/src/services/payload-signer.ts rename to lambdas/https-client-lambda/src/services/payload-signer.ts index cf69cac8..e2174b76 100644 --- a/lambdas/client-transform-filter-lambda/src/services/payload-signer.ts +++ b/lambdas/https-client-lambda/src/services/payload-signer.ts @@ -2,9 +2,9 @@ import { createHmac } from "node:crypto"; import type { ClientCallbackPayload } from "@nhs-notify-client-callbacks/models"; export function signPayload( - payload: ClientCallbackPayload, applicationId: string, apiKey: string, + payload: ClientCallbackPayload, ): string { return createHmac("sha256", `${applicationId}.${apiKey}`) .update(JSON.stringify(payload)) diff --git a/lambdas/https-client-lambda/src/services/record-result.lua b/lambdas/https-client-lambda/src/services/record-result.lua new file mode 100644 index 00000000..89b81279 --- /dev/null +++ b/lambdas/https-client-lambda/src/services/record-result.lua @@ -0,0 +1,92 @@ +-- record-result.lua +-- Atomic two-window sliding circuit-breaker state update. +-- KEYS[1] = cb:{targetId} (circuit breaker hash) +-- ARGV[1] = now (epoch ms) +-- ARGV[2] = success ("1" or "0") +-- ARGV[3] = cbWindowPeriodMs +-- ARGV[4] = cbErrorThreshold (float, e.g. "0.5") +-- ARGV[5] = cbMinAttempts (integer) +-- ARGV[6] = cbCooldownMs +-- ARGV[7] = decayPeriodMs + +local cb_key = KEYS[1] +local now = tonumber(ARGV[1]) +local success = ARGV[2] == "1" +local windowPeriodMs = tonumber(ARGV[3]) +local errorThreshold = tonumber(ARGV[4]) +local minAttempts = tonumber(ARGV[5]) +local cooldownMs = tonumber(ARGV[6]) +local decayPeriodMs = tonumber(ARGV[7]) + +-- Load current state +local opened_until_ms = tonumber(redis.call("HGET", cb_key, "opened_until_ms") or "0") or 0 +local cb_window_from = tonumber(redis.call("HGET", cb_key, "cb_window_from") or "0") or 0 +local cb_failures = tonumber(redis.call("HGET", cb_key, "cb_failures") or "0") or 0 +local cb_attempts = tonumber(redis.call("HGET", cb_key, "cb_attempts") or "0") or 0 +local cb_prev_failures = tonumber(redis.call("HGET", cb_key, "cb_prev_failures") or "0") or 0 +local cb_prev_attempts = tonumber(redis.call("HGET", cb_key, "cb_prev_attempts") or "0") or 0 + +-- Initialise window if not set +if cb_window_from == 0 then + cb_window_from = now +end + +-- Check for window expiry and roll +if (now - cb_window_from) >= windowPeriodMs then + cb_prev_failures = cb_failures + cb_prev_attempts = cb_attempts + cb_failures = 0 + cb_attempts = 0 + cb_window_from = now +end + +-- Increment counters +cb_attempts = cb_attempts + 1 +if not success then + cb_failures = cb_failures + 1 +end + +-- Compute two-window blended error rate +local elapsed_in_window = now - cb_window_from +local prev_weight = 0 +if windowPeriodMs > 0 and elapsed_in_window < windowPeriodMs then + prev_weight = 1 - (elapsed_in_window / windowPeriodMs) +end + +local blended_failures = cb_prev_failures * prev_weight + cb_failures +local blended_attempts = cb_prev_attempts * prev_weight + cb_attempts + +local state = "closed" + +-- Check if we should open the circuit +if blended_attempts >= minAttempts and blended_attempts > 0 then + local error_rate = blended_failures / blended_attempts + if error_rate >= errorThreshold then + opened_until_ms = now + cooldownMs + state = "opened" + end +end + +-- During active decay, preserve opened_until_ms as decay start marker +if opened_until_ms > 0 and now >= opened_until_ms then + local elapsed_since_close = now - opened_until_ms + if elapsed_since_close >= decayPeriodMs then + opened_until_ms = 0 + end +end + +-- Write updated state +redis.call("HSET", cb_key, + "opened_until_ms", tostring(opened_until_ms), + "cb_window_from", tostring(cb_window_from), + "cb_failures", tostring(cb_failures), + "cb_attempts", tostring(cb_attempts), + "cb_prev_failures", tostring(cb_prev_failures), + "cb_prev_attempts", tostring(cb_prev_attempts) +) + +if state == "opened" then + return cjson.encode({ ok = false, state = "opened" }) +end + +return cjson.encode({ ok = true, state = "closed" }) diff --git a/lambdas/https-client-lambda/src/services/sqs-visibility.ts b/lambdas/https-client-lambda/src/services/sqs-visibility.ts new file mode 100644 index 00000000..e6fe2720 --- /dev/null +++ b/lambdas/https-client-lambda/src/services/sqs-visibility.ts @@ -0,0 +1,21 @@ +import { ChangeMessageVisibilityCommand, SQSClient } from "@aws-sdk/client-sqs"; + +const sqsClient = new SQSClient({}); + +export async function changeVisibility( + receiptHandle: string, + visibilityTimeoutSeconds: number, +): Promise { + const { QUEUE_URL } = process.env; + if (!QUEUE_URL) { + throw new Error("QUEUE_URL is required"); + } + + await sqsClient.send( + new ChangeMessageVisibilityCommand({ + QueueUrl: QUEUE_URL, + ReceiptHandle: receiptHandle, + VisibilityTimeout: Math.floor(visibilityTimeoutSeconds), + }), + ); +} diff --git a/lambdas/https-client-lambda/src/services/ssm-applications-map.ts b/lambdas/https-client-lambda/src/services/ssm-applications-map.ts new file mode 100644 index 00000000..a69e7f65 --- /dev/null +++ b/lambdas/https-client-lambda/src/services/ssm-applications-map.ts @@ -0,0 +1,62 @@ +import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm"; +import { logger } from "services/logger"; + +const ssmClient = new SSMClient({}); + +let cachedMap: Map | undefined; + +async function loadMap(): Promise> { + if (cachedMap) { + return cachedMap; + } + + const { APPLICATIONS_MAP_PARAMETER_NAME } = process.env; + if (!APPLICATIONS_MAP_PARAMETER_NAME) { + throw new Error("APPLICATIONS_MAP_PARAMETER_NAME is required"); + } + + const response = await ssmClient.send( + new GetParameterCommand({ + Name: APPLICATIONS_MAP_PARAMETER_NAME, + WithDecryption: true, + }), + ); + + if (!response.Parameter?.Value) { + throw new Error( + `SSM parameter '${APPLICATIONS_MAP_PARAMETER_NAME}' not found or has no value`, + ); + } + + let parsed: Record; + try { + parsed = JSON.parse(response.Parameter.Value) as Record; + } catch { + throw new Error( + `SSM parameter '${APPLICATIONS_MAP_PARAMETER_NAME}' contains invalid JSON`, + ); + } + + cachedMap = new Map(Object.entries(parsed)); + logger.info("Applications map loaded from SSM", { + parameterName: APPLICATIONS_MAP_PARAMETER_NAME, + }); + return cachedMap; +} + +export async function getApplicationId(clientId: string): Promise { + const map = await loadMap(); + const applicationId = map.get(clientId); + + if (!applicationId) { + throw new Error( + `No applicationId found for clientId '${clientId}' in SSM map`, + ); + } + + return applicationId; +} + +export function resetCache(): void { + cachedMap = undefined; +} diff --git a/lambdas/https-client-lambda/tsconfig.json b/lambdas/https-client-lambda/tsconfig.json new file mode 100644 index 00000000..a50e6fc0 --- /dev/null +++ b/lambdas/https-client-lambda/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "isolatedModules": true, + "paths": { + "*": [ + "./src/*" + ] + } + }, + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*" + ] +} diff --git a/lambdas/mock-webhook-lambda/src/__tests__/index.test.ts b/lambdas/mock-webhook-lambda/src/__tests__/index.test.ts index 6f3cc917..d43ef01e 100644 --- a/lambdas/mock-webhook-lambda/src/__tests__/index.test.ts +++ b/lambdas/mock-webhook-lambda/src/__tests__/index.test.ts @@ -1,6 +1,14 @@ +import { X509Certificate } from "node:crypto"; import type { APIGatewayProxyEvent } from "aws-lambda"; import { handler } from "index"; +jest.mock("node:crypto", () => ({ + ...jest.requireActual("node:crypto"), + X509Certificate: jest.fn(), +})); + +const mockX509Certificate = X509Certificate as unknown as jest.Mock; + const TEST_API_KEY = "test-api-key"; jest.mock("@nhs-notify-client-callbacks/logger", () => { @@ -32,6 +40,28 @@ const createMockEvent = ( ): APIGatewayProxyEvent => ({ body, headers, rawPath }) as unknown as APIGatewayProxyEvent; +const createAlbEvent = ( + body: string | null, + headers: Record = DEFAULT_HEADERS, + extraHeaders: Record = {}, +): APIGatewayProxyEvent => + ({ + body, + path: "/target-abc", + httpMethod: "POST", + headers: { ...headers, ...extraHeaders }, + requestContext: { + elb: { + targetGroupArn: + "arn:aws:elasticloadbalancing:eu-west-2:123456789012:targetgroup/mock/abc", + }, + }, + }) as unknown as APIGatewayProxyEvent; + +const FAKE_CERT_HEADER = encodeURIComponent( + "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----", +); + describe("Mock Webhook Lambda", () => { beforeAll(() => { process.env.API_KEY = TEST_API_KEY; @@ -381,3 +411,110 @@ describe("Mock Webhook Lambda", () => { }); }); }); + +describe("ALB mTLS certificate verification", () => { + beforeAll(() => { + process.env.API_KEY = TEST_API_KEY; + }); + + afterAll(() => { + delete process.env.API_KEY; + }); + + beforeEach(() => { + mockX509Certificate.mockReset(); + mockX509Certificate.mockImplementation(() => ({ + validFrom: new Date(Date.now() - 86_400_000).toString(), + validTo: new Date(Date.now() + 86_400_000).toString(), + })); + }); + + it("returns 401 when ALB invocation has no client certificate header", async () => { + const event = createAlbEvent(JSON.stringify({ data: [] })); + const result = await handler(event); + + expect(result.statusCode).toBe(401); + const body = JSON.parse(result.body); + expect(body.message).toBe("Mutual TLS authentication required"); + }); + + it("returns 401 when client certificate header cannot be parsed", async () => { + mockX509Certificate.mockImplementationOnce(() => { + throw new Error("Invalid certificate"); + }); + const event = createAlbEvent( + JSON.stringify({ data: [] }), + DEFAULT_HEADERS, + { "x-amzn-mtls-clientcert": FAKE_CERT_HEADER }, + ); + const result = await handler(event); + + expect(result.statusCode).toBe(401); + const body = JSON.parse(result.body); + expect(body.message).toBe("Mutual TLS authentication required"); + }); + + it("returns 401 when client certificate is expired", async () => { + mockX509Certificate.mockImplementationOnce(() => ({ + validFrom: new Date(Date.now() - 172_800_000).toString(), + validTo: new Date(Date.now() - 86_400_000).toString(), + })); + const event = createAlbEvent( + JSON.stringify({ data: [] }), + DEFAULT_HEADERS, + { "x-amzn-mtls-clientcert": FAKE_CERT_HEADER }, + ); + const result = await handler(event); + + expect(result.statusCode).toBe(401); + const body = JSON.parse(result.body); + expect(body.message).toBe("Mutual TLS authentication required"); + }); + + it("proceeds past cert check to API key validation when cert is valid", async () => { + const event = createAlbEvent( + JSON.stringify({ data: [] }), + { "x-api-key": "wrong-key" }, + { "x-amzn-mtls-clientcert": FAKE_CERT_HEADER }, + ); + const result = await handler(event); + + expect(result.statusCode).toBe(401); + const body = JSON.parse(result.body); + expect(body.message).toBe("Unauthorized"); + }); + + it("processes request successfully when certificate is valid and API key is correct", async () => { + const callback = { + data: [ + { + type: "MessageStatus", + attributes: { + messageId: "msg-alb-mtls", + messageReference: "ref-alb", + messageStatus: "delivered", + timestamp: "2026-01-01T00:00:00Z", + }, + links: { message: "some-link" }, + meta: { idempotencyKey: "idem-key-alb" }, + }, + ], + }; + const event = createAlbEvent(JSON.stringify(callback), DEFAULT_HEADERS, { + "x-amzn-mtls-clientcert": FAKE_CERT_HEADER, + }); + const result = await handler(event); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.message).toBe("Callback received"); + }); + + it("non-ALB invocations skip certificate verification", async () => { + const event = createMockEvent(JSON.stringify({ data: [] })); + const result = await handler(event); + + const body = JSON.parse(result.body); + expect(body.message).not.toBe("Mutual TLS authentication required"); + }); +}); diff --git a/lambdas/mock-webhook-lambda/src/index.ts b/lambdas/mock-webhook-lambda/src/index.ts index 081ef3b9..9d4381d8 100644 --- a/lambdas/mock-webhook-lambda/src/index.ts +++ b/lambdas/mock-webhook-lambda/src/index.ts @@ -1,9 +1,33 @@ +import { X509Certificate } from "node:crypto"; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; import { Logger } from "@nhs-notify-client-callbacks/logger"; import type { ClientCallbackPayload } from "@nhs-notify-client-callbacks/models"; const logger = new Logger(); +function verifyClientCertificate(certHeader: string | undefined): { + valid: boolean; + reason?: string; +} { + if (!certHeader) { + return { valid: false, reason: "No client certificate provided" }; + } + try { + const pem = decodeURIComponent(certHeader); + const cert = new X509Certificate(pem); + const now = new Date(); + if (now < new Date(cert.validFrom) || now > new Date(cert.validTo)) { + return { + valid: false, + reason: "Client certificate is not within its validity period", + }; + } + return { valid: true }; + } catch { + return { valid: false, reason: "Failed to parse client certificate" }; + } +} + function isClientCallbackPayload( value: unknown, ): value is ClientCallbackPayload { @@ -36,15 +60,35 @@ function isClientCallbackPayload( async function buildResponse( event: APIGatewayProxyEvent, ): Promise { - const eventWithFunctionUrlFields = event as APIGatewayProxyEvent & { + const eventWithContextFields = event as APIGatewayProxyEvent & { rawPath?: string; - requestContext?: { http?: { method?: string } }; + requestContext?: { + http?: { method?: string }; + elb?: { targetGroupArn: string }; + }; }; const headers = Object.fromEntries( Object.entries(event.headers).map(([k, v]) => [String(k).toLowerCase(), v]), ) as Record; - const path = event.path ?? eventWithFunctionUrlFields.rawPath; + const path = event.path ?? eventWithContextFields.rawPath; + + const isAlbInvocation = Boolean(eventWithContextFields.requestContext?.elb); + if (isAlbInvocation) { + const certResult = verifyClientCertificate( + headers["x-amzn-mtls-clientcert"], + ); + if (!certResult.valid) { + logger.error("mTLS verification failed", { reason: certResult.reason }); + return { + statusCode: 401, + body: JSON.stringify({ message: "Mutual TLS authentication required" }), + }; + } + logger.info("mTLS client certificate verified", { + fingerprint: headers["x-amzn-mtls-clientcert-fingerprint"] ?? "", + }); + } logger.info("Mock webhook invoked", { path, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdad9338..e1aa1af5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: app: + '@redis/client': + specifier: ^1.5.14 + version: 1.6.1 async-wait-until: specifier: ^2.0.31 version: 2.0.31 @@ -18,6 +21,9 @@ catalogs: p-map: specifier: ^4.0.0 version: 4.0.0 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 pino: specifier: ^10.3.1 version: 10.3.1 @@ -40,6 +46,9 @@ catalogs: '@aws-sdk/client-s3': specifier: ^3.1024.0 version: 3.1029.0 + '@aws-sdk/client-secrets-manager': + specifier: ^3.1023.0 + version: 3.1029.0 '@aws-sdk/client-sqs': specifier: ^3.1023.0 version: 3.1026.0 @@ -267,6 +276,9 @@ importers: '@aws-sdk/client-ssm': specifier: catalog:aws version: 3.1029.0 + '@nhs-notify-client-callbacks/config-cache': + specifier: workspace:* + version: link:../../src/config-cache '@nhs-notify-client-callbacks/logger': specifier: workspace:* version: link:../../src/logger @@ -306,7 +318,62 @@ importers: version: 9.39.4(jiti@2.6.1) jest: specifier: catalog:test - version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)) + version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) + typescript: + specifier: catalog:tools + version: 5.9.3 + + lambdas/https-client-lambda: + dependencies: + '@aws-sdk/client-s3': + specifier: catalog:aws + version: 3.1029.0 + '@aws-sdk/client-secrets-manager': + specifier: catalog:aws + version: 3.1029.0 + '@aws-sdk/client-sqs': + specifier: catalog:aws + version: 3.1026.0 + '@aws-sdk/client-ssm': + specifier: catalog:aws + version: 3.1029.0 + '@nhs-notify-client-callbacks/config-cache': + specifier: workspace:* + version: link:../../src/config-cache + '@nhs-notify-client-callbacks/logger': + specifier: workspace:* + version: link:../../src/logger + '@nhs-notify-client-callbacks/models': + specifier: workspace:* + version: link:../../src/models + '@redis/client': + specifier: catalog:app + version: 1.6.1 + aws-embedded-metrics: + specifier: catalog:app + version: 4.2.1 + esbuild: + specifier: catalog:tools + version: 0.28.0 + devDependencies: + '@tsconfig/node22': + specifier: catalog:tools + version: 22.0.5 + '@types/aws-lambda': + specifier: catalog:tools + version: 8.10.161 + '@types/jest': + specifier: catalog:test + version: 30.0.0 + '@types/node': + specifier: catalog:tools + version: 24.12.0 + eslint: + specifier: catalog:lint + version: 9.39.4(jiti@2.6.1) + jest: + specifier: catalog:test + version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) typescript: specifier: catalog:tools version: 5.9.3 @@ -340,13 +407,41 @@ importers: version: 9.39.4(jiti@2.6.1) jest: specifier: catalog:test - version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)) + version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) jest-html-reporter: specifier: catalog:test - version: 4.4.0(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3))) + version: 4.4.0(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3))) + ts-jest: + specifier: catalog:test + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.28.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: catalog:tools + version: 5.9.3 + + src/config-cache: + dependencies: + '@nhs-notify-client-callbacks/models': + specifier: workspace:* + version: link:../models + devDependencies: + '@tsconfig/node22': + specifier: catalog:tools + version: 22.0.5 + '@types/jest': + specifier: catalog:test + version: 30.0.0 + '@types/node': + specifier: catalog:tools + version: 24.12.0 + eslint: + specifier: catalog:lint + version: 9.39.4(jiti@2.6.1) + jest: + specifier: catalog:test + version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) ts-jest: specifier: catalog:test - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.28.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.28.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: catalog:tools version: 5.9.3 @@ -371,10 +466,10 @@ importers: version: 9.39.4(jiti@2.6.1) jest: specifier: catalog:test - version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)) + version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) ts-jest: specifier: catalog:test - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.28.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.28.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: catalog:tools version: 5.9.3 @@ -399,10 +494,10 @@ importers: version: 9.39.4(jiti@2.6.1) jest: specifier: catalog:test - version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)) + version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) ts-jest: specifier: catalog:test - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.28.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.28.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: catalog:tools version: 5.9.3 @@ -445,7 +540,7 @@ importers: version: 9.39.4(jiti@2.6.1) jest: specifier: catalog:test - version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)) + version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) typescript: specifier: catalog:tools version: 5.9.3 @@ -482,7 +577,7 @@ importers: version: 9.39.4(jiti@2.6.1) jest: specifier: catalog:test - version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)) + version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) typescript: specifier: catalog:tools version: 5.9.3 @@ -529,6 +624,9 @@ importers: '@nhs-notify-client-callbacks/models': specifier: workspace:* version: link:../../src/models + picocolors: + specifier: catalog:app + version: 1.1.1 table: specifier: catalog:app version: 6.9.0 @@ -553,10 +651,10 @@ importers: version: 9.39.4(jiti@2.6.1) jest: specifier: catalog:test - version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)) + version: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) ts-jest: specifier: catalog:test - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.28.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.28.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)))(typescript@5.9.3) tsx: specifier: catalog:tools version: 4.21.0 @@ -605,6 +703,10 @@ packages: resolution: {integrity: sha512-OuA8RZTxsAaHDcI25j2NGLMaYFI2WpJdDzK3uLmVBmaHwjQKQZOUDVVBcln8pNo3IgkY+HRSJhRR4/xlM//UyQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-secrets-manager@3.1029.0': + resolution: {integrity: sha512-OtNiJSEXA8+KkFA1aS24BOFkJoRlxwJ8tBLiUUYKVwLu8L3Smfz2oj4BJwRlv0FzWTqrmJkFC8kly/cAZqU2UQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-sqs@3.1026.0': resolution: {integrity: sha512-b7z2WI1tqObk4U7vUbmBfXIeFhxKbFr7xQ4rWi879iFl5aSPvpd1WAmLi6z1boVKTEwEqHALuE5MyGBHhOCy5A==} engines: {node: '>=20.0.0'} @@ -1567,6 +1669,10 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -2323,6 +2429,10 @@ packages: resolution: {integrity: sha512-uyzC+PpMMRawbouHO+3mlisr3QfEDObmo2pN4oTTF6dZncZgpIzdasZx0tRBFI1dMsqCLZZXMtz8cUuvYqHdbw==} engines: {node: '>=20 <=24'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -2894,6 +3004,10 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -4287,6 +4401,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -4571,6 +4688,50 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-secrets-manager@3.1029.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.27 + '@aws-sdk/credential-provider-node': 3.972.30 + '@aws-sdk/middleware-host-header': 3.972.9 + '@aws-sdk/middleware-logger': 3.972.9 + '@aws-sdk/middleware-recursion-detection': 3.972.10 + '@aws-sdk/middleware-user-agent': 3.972.29 + '@aws-sdk/region-config-resolver': 3.972.11 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-endpoints': 3.996.6 + '@aws-sdk/util-user-agent-browser': 3.972.9 + '@aws-sdk/util-user-agent-node': 3.973.15 + '@smithy/config-resolver': 4.4.14 + '@smithy/core': 3.23.14 + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/hash-node': 4.2.13 + '@smithy/invalid-dependency': 4.2.13 + '@smithy/middleware-content-length': 4.2.13 + '@smithy/middleware-endpoint': 4.4.29 + '@smithy/middleware-retry': 4.5.0 + '@smithy/middleware-serde': 4.2.17 + '@smithy/middleware-stack': 4.2.13 + '@smithy/node-config-provider': 4.3.13 + '@smithy/node-http-handler': 4.5.2 + '@smithy/protocol-http': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.45 + '@smithy/util-defaults-mode-node': 4.2.49 + '@smithy/util-endpoints': 3.3.4 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-retry': 4.3.0 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-sqs@3.1026.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -5539,6 +5700,41 @@ snapshots: jest-util: 30.3.0 slash: 3.0.0 + '@jest/core@30.3.0(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3))': + dependencies: + '@jest/console': 30.3.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 + '@types/node': 25.5.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.4.0 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.3.0 + jest-config: 30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) + jest-haste-map: 30.3.0 + jest-message-util: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-resolve-dependencies: 30.3.0 + jest-runner: 30.3.0 + jest-runtime: 30.3.0 + jest-snapshot: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + jest-watcher: 30.3.0 + pretty-format: 30.3.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + '@jest/core@30.3.0(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3))': dependencies: '@jest/console': 30.3.0 @@ -5835,6 +6031,12 @@ snapshots: '@pkgr/core@0.2.9': {} + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + '@rtsao/scc@1.1.0': {} '@sinclair/typebox@0.34.49': {} @@ -6751,6 +6953,8 @@ snapshots: util: 0.12.5 uuid: 8.3.2 + cluster-key-slot@1.1.2: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -7535,6 +7739,8 @@ snapshots: generator-function@2.0.1: {} + generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -7908,15 +8114,15 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)): + jest-cli@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)): dependencies: - '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)) + '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)) + jest-config: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) jest-util: 30.3.0 jest-validate: 30.3.0 yargs: 17.7.2 @@ -7946,7 +8152,7 @@ snapshots: - supports-color - ts-node - jest-config@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)): + jest-config@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -7973,7 +8179,39 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 24.12.0 - ts-node: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) + ts-node: 10.9.2(@types/node@24.12.0)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.0 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.3.0 + '@jest/types': 30.3.0 + babel-jest: 30.3.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.3.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-runner: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + parse-json: 5.2.0 + pretty-format: 30.3.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 25.5.0 + ts-node: 10.9.2(@types/node@24.12.0)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -8054,13 +8292,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - jest-html-reporter@4.4.0(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3))): + jest-html-reporter@4.4.0(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3))): dependencies: '@jest/reporters': 30.3.0 '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 dateformat: 3.0.2 - jest: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)) + jest: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) mkdirp: 1.0.4 strip-ansi: 6.0.1 xmlbuilder: 15.0.0 @@ -8253,12 +8491,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)): + jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)): dependencies: - '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)) + '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) '@jest/types': 30.3.0 import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)) + jest-cli: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -9029,12 +9267,12 @@ snapshots: picomatch: 4.0.4 typescript: 5.9.3 - ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.28.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.28.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.9 - jest: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3)) + jest: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -9071,6 +9309,25 @@ snapshots: esbuild: 0.28.0 jest-util: 30.3.0 + ts-node@10.9.2(@types/node@24.12.0)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 24.12.0 + acorn: 8.16.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -9327,6 +9584,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yaml@2.8.3: {} yargs-parser@20.2.9: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5b7a321c..cdf0803b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,10 +8,12 @@ blockExoticSubdeps: true catalogs: app: + "@redis/client": "^1.5.14" async-wait-until: "^2.0.31" aws-embedded-metrics: "^4.2.1" cloudevents: "^10.0.0" p-map: "^4.0.0" + picocolors: "^1.1.1" pino: "^10.3.1" table: "^6.9.0" yargs: "^17.7.2" @@ -20,6 +22,7 @@ catalogs: "@aws-sdk/client-cloudwatch": "^3.1025.0" "@aws-sdk/client-cloudwatch-logs": "^3.1023.0" "@aws-sdk/client-s3": "^3.1024.0" + "@aws-sdk/client-secrets-manager": "^3.1023.0" "@aws-sdk/client-sqs": "^3.1023.0" "@aws-sdk/client-ssm": "^3.1025.0" "@aws-sdk/client-sts": "^3.1023.0" diff --git a/scripts/config/pre-commit.yaml b/scripts/config/pre-commit.yaml index a7619797..1e1da873 100644 --- a/scripts/config/pre-commit.yaml +++ b/scripts/config/pre-commit.yaml @@ -8,6 +8,7 @@ repos: - id: check-added-large-files - id: check-symlinks - id: detect-private-key + exclude: 'lambdas/https-client-lambda/src/__tests__/tls-agent-factory\.test\.ts' - id: end-of-file-fixer - id: forbid-new-submodules - id: mixed-line-ending diff --git a/src/config-cache/jest.config.ts b/src/config-cache/jest.config.ts new file mode 100644 index 00000000..6ecf333b --- /dev/null +++ b/src/config-cache/jest.config.ts @@ -0,0 +1,14 @@ +import { nodeJestConfig } from "../../jest.config.base.ts"; + +export default { + ...nodeJestConfig, + coverageThreshold: { + global: { + ...nodeJestConfig.coverageThreshold?.global, + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}; diff --git a/src/config-cache/package.json b/src/config-cache/package.json new file mode 100644 index 00000000..61bf815f --- /dev/null +++ b/src/config-cache/package.json @@ -0,0 +1,32 @@ +{ + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "dependencies": { + "@nhs-notify-client-callbacks/models": "workspace:*" + }, + "devDependencies": { + "@tsconfig/node22": "catalog:tools", + "@types/jest": "catalog:test", + "@types/node": "catalog:tools", + "eslint": "catalog:lint", + "jest": "catalog:test", + "ts-jest": "catalog:test", + "typescript": "catalog:tools" + }, + "engines": { + "node": ">=24.14.1" + }, + "name": "@nhs-notify-client-callbacks/config-cache", + "private": true, + "scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "version": "0.0.1" +} diff --git a/src/config-cache/src/__tests__/config-cache.test.ts b/src/config-cache/src/__tests__/config-cache.test.ts new file mode 100644 index 00000000..179a178a --- /dev/null +++ b/src/config-cache/src/__tests__/config-cache.test.ts @@ -0,0 +1,75 @@ +import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; +import { ConfigCache } from "config-cache"; + +const createConfig = (clientId: string): ClientSubscriptionConfiguration => ({ + clientId, + subscriptions: [], + targets: [], +}); + +describe("ConfigCache", () => { + it("stores and retrieves configuration", () => { + const cache = new ConfigCache(60_000); + const config = createConfig("client-1"); + + cache.set("client-1", config); + + expect(cache.get("client-1")).toEqual(config); + }); + + it("returns undefined for non-existent key", () => { + const cache = new ConfigCache(60_000); + + expect(cache.get("non-existent")).toBeUndefined(); + }); + + it("returns cached value without re-fetch when within TTL", () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-01-01T10:00:00Z")); + + const cache = new ConfigCache(5000); + const config = createConfig("client-1"); + + cache.set("client-1", config); + + jest.advanceTimersByTime(4999); + + expect(cache.get("client-1")).toEqual(config); + + jest.useRealTimers(); + }); + + it("returns undefined for expired entries after TTL", () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-01-01T10:00:00Z")); + + const cache = new ConfigCache(1000); + const config = createConfig("client-1"); + + cache.set("client-1", config); + expect(cache.get("client-1")).toEqual(config); + + jest.advanceTimersByTime(1001); + + expect(cache.get("client-1")).toBeUndefined(); + + jest.useRealTimers(); + }); + + it("clears all entries", () => { + const cache = new ConfigCache(60_000); + const configA = createConfig("client-a"); + const configB = createConfig("client-b"); + + cache.set("client-a", configA); + cache.set("client-b", configB); + + expect(cache.get("client-a")).toEqual(configA); + expect(cache.get("client-b")).toEqual(configB); + + cache.clear(); + + expect(cache.get("client-a")).toBeUndefined(); + expect(cache.get("client-b")).toBeUndefined(); + }); +}); diff --git a/lambdas/client-transform-filter-lambda/src/services/config-cache.ts b/src/config-cache/src/config-cache.ts similarity index 100% rename from lambdas/client-transform-filter-lambda/src/services/config-cache.ts rename to src/config-cache/src/config-cache.ts diff --git a/src/config-cache/src/index.ts b/src/config-cache/src/index.ts new file mode 100644 index 00000000..1da1a0f1 --- /dev/null +++ b/src/config-cache/src/index.ts @@ -0,0 +1 @@ +export { ConfigCache } from "./config-cache"; diff --git a/src/config-cache/tsconfig.json b/src/config-cache/tsconfig.json new file mode 100644 index 00000000..a50e6fc0 --- /dev/null +++ b/src/config-cache/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "isolatedModules": true, + "paths": { + "*": [ + "./src/*" + ] + } + }, + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*" + ] +} diff --git a/src/models/src/__tests__/client-config-schema.test.ts b/src/models/src/__tests__/client-config-schema.test.ts index da1e5429..d166c037 100644 --- a/src/models/src/__tests__/client-config-schema.test.ts +++ b/src/models/src/__tests__/client-config-schema.test.ts @@ -19,6 +19,8 @@ const expectFailedParse = ( return result; }; +const VALID_SPKI_HASH = "KL/yFsVH+gnkkzdQ+DSlV8xMQOMehksgT6aOqQviOu8="; + const createValidConfig = (): ClientSubscriptionConfiguration => ({ clientId: "client-1", subscriptions: [ @@ -45,6 +47,8 @@ const createValidConfig = (): ClientSubscriptionConfiguration => ({ invocationMethod: "POST", invocationRateLimit: 10, apiKey: { headerName: "x-api-key", headerValue: "secret" }, + mtls: { enabled: true }, + certPinning: { enabled: true, spkiHash: VALID_SPKI_HASH }, }, ], }); @@ -147,4 +151,105 @@ describe("parseClientSubscriptionConfiguration", () => { }), ]); }); + + it("parses a valid config with mtls, certPinning, and delivery fields", () => { + const config = createValidConfig(); + config.targets[0].delivery = { + maxRetryDurationSeconds: 7200, + circuitBreaker: { enabled: true }, + }; + + expect(parseClientSubscriptionConfiguration(config)).toEqual({ + success: true, + data: config, + }); + }); + + it("returns a failed parse result when mtls field is missing", () => { + const config = createValidConfig(); + const target = config.targets[0] as Record; + delete target.mtls; + + const result = expectFailedParse( + parseClientSubscriptionConfiguration(config), + ); + + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: expect.arrayContaining(["targets", 0, "mtls"]), + }), + ]), + ); + }); + + it("returns a failed parse result when spkiHash has an invalid pattern", () => { + const config = createValidConfig(); + config.targets[0].certPinning.spkiHash = "not-a-valid-hash"; + + const result = expectFailedParse( + parseClientSubscriptionConfiguration(config), + ); + + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "Invalid SPKI hash", + }), + ]), + ); + }); + + it("returns a failed parse result when certPinning.enabled is true without spkiHash", () => { + const config = createValidConfig(); + config.targets[0].certPinning = { enabled: true }; + + const result = expectFailedParse( + parseClientSubscriptionConfiguration(config), + ); + + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "spkiHash is required when certPinning is enabled", + }), + ]), + ); + }); + + it("returns a failed parse result when maxRetryDurationSeconds is below 60", () => { + const config = createValidConfig(); + config.targets[0].delivery = { maxRetryDurationSeconds: 59 }; + + const result = expectFailedParse( + parseClientSubscriptionConfiguration(config), + ); + + expect(result.success).toBe(false); + }); + + it("returns a failed parse result when maxRetryDurationSeconds is above 43200", () => { + const config = createValidConfig(); + config.targets[0].delivery = { maxRetryDurationSeconds: 43_201 }; + + const result = expectFailedParse( + parseClientSubscriptionConfiguration(config), + ); + + expect(result.success).toBe(false); + }); + + it("accepts maxRetryDurationSeconds at boundary value 60", () => { + const config = createValidConfig(); + config.targets[0].delivery = { maxRetryDurationSeconds: 60 }; + + expect(parseClientSubscriptionConfiguration(config).success).toBe(true); + }); + + it("accepts maxRetryDurationSeconds at boundary value 43200", () => { + const config = createValidConfig(); + config.targets[0].delivery = { maxRetryDurationSeconds: 43_200 }; + + expect(parseClientSubscriptionConfiguration(config).success).toBe(true); + }); }); diff --git a/src/models/src/client-config-schema.ts b/src/models/src/client-config-schema.ts index b56a9439..5da8479d 100644 --- a/src/models/src/client-config-schema.ts +++ b/src/models/src/client-config-schema.ts @@ -22,6 +22,20 @@ const httpsUrlSchema = z.string().refine( }, ); +const SPKI_HASH_PATTERN = /^[A-Za-z0-9+/]{43}=$/; + +const certPinningSchema = z + .object({ + enabled: z.boolean(), + spkiHash: z + .string() + .regex(SPKI_HASH_PATTERN, "Invalid SPKI hash") + .optional(), + }) + .refine((val) => !val.enabled || val.spkiHash !== undefined, { + message: "spkiHash is required when certPinning is enabled", + }); + const targetSchema = z.object({ targetId: z.string(), type: z.literal("API"), @@ -32,6 +46,20 @@ const targetSchema = z.object({ headerName: z.string(), headerValue: z.string(), }), + mtls: z.object({ + enabled: z.boolean(), + }), + certPinning: certPinningSchema, + delivery: z + .object({ + maxRetryDurationSeconds: z.number().min(60).max(43_200).optional(), + circuitBreaker: z + .object({ + enabled: z.boolean(), + }) + .optional(), + }) + .optional(), }); const baseSubscriptionSchema = z.object({ diff --git a/src/models/src/client-config.ts b/src/models/src/client-config.ts index 84116353..9b434596 100644 --- a/src/models/src/client-config.ts +++ b/src/models/src/client-config.ts @@ -15,6 +15,19 @@ export type CallbackTarget = { headerName: string; headerValue: string; }; + mtls: { + enabled: boolean; + }; + certPinning: { + enabled: boolean; + spkiHash?: string; + }; + delivery?: { + maxRetryDurationSeconds?: number; + circuitBreaker?: { + enabled: boolean; + }; + }; }; type SubscriptionConfigurationBase = { diff --git a/tests/integration/fixtures/subscriptions/mock-client-1.json b/tests/integration/fixtures/subscriptions/mock-client-1.json index 1e76ad65..ed70c73f 100644 --- a/tests/integration/fixtures/subscriptions/mock-client-1.json +++ b/tests/integration/fixtures/subscriptions/mock-client-1.json @@ -35,9 +35,15 @@ "headerName": "x-api-key", "headerValue": "REPLACED_BY_TERRAFORM" }, + "certPinning": { + "enabled": false + }, "invocationEndpoint": "https://REPLACED_BY_TERRAFORM", "invocationMethod": "POST", "invocationRateLimit": 10, + "mtls": { + "enabled": false + }, "targetId": "target-23b2ee2f-8e81-43cd-9bb8-5ea30a09f779", "type": "API" } diff --git a/tests/integration/fixtures/subscriptions/mock-client-2.json b/tests/integration/fixtures/subscriptions/mock-client-2.json index ee7091cd..ab5460c9 100644 --- a/tests/integration/fixtures/subscriptions/mock-client-2.json +++ b/tests/integration/fixtures/subscriptions/mock-client-2.json @@ -20,9 +20,15 @@ "headerName": "x-api-key", "headerValue": "REPLACED_BY_TERRAFORM" }, + "certPinning": { + "enabled": false + }, "invocationEndpoint": "https://REPLACED_BY_TERRAFORM", "invocationMethod": "POST", "invocationRateLimit": 10, + "mtls": { + "enabled": false + }, "targetId": "target-1f3aa57d-c0b6-4a0a-a8e9-c7f97f1e27e7", "type": "API" }, @@ -31,9 +37,15 @@ "headerName": "x-api-key", "headerValue": "REPLACED_BY_TERRAFORM" }, + "certPinning": { + "enabled": false + }, "invocationEndpoint": "https://REPLACED_BY_TERRAFORM", "invocationMethod": "POST", "invocationRateLimit": 10, + "mtls": { + "enabled": false + }, "targetId": "target-c23f4ad8-2b6f-4510-b5b6-40f2b7fbbec5", "type": "API" } diff --git a/tests/integration/fixtures/subscriptions/mock-client-circuit-breaker.json b/tests/integration/fixtures/subscriptions/mock-client-circuit-breaker.json new file mode 100644 index 00000000..783681c5 --- /dev/null +++ b/tests/integration/fixtures/subscriptions/mock-client-circuit-breaker.json @@ -0,0 +1,40 @@ +{ + "clientId": "mock-client-circuit-breaker", + "subscriptions": [ + { + "messageStatuses": [ + "DELIVERED", + "FAILED" + ], + "subscriptionId": "sub-cb-msg-001", + "subscriptionType": "MessageStatus", + "targetIds": [ + "target-cb-001" + ] + } + ], + "targets": [ + { + "apiKey": { + "headerName": "x-api-key", + "headerValue": "REPLACED_BY_TERRAFORM" + }, + "certPinning": { + "enabled": false + }, + "delivery": { + "circuitBreaker": { + "enabled": true + } + }, + "invocationEndpoint": "https://REPLACED_BY_TERRAFORM", + "invocationMethod": "POST", + "invocationRateLimit": 10, + "mtls": { + "enabled": false + }, + "targetId": "target-cb-001", + "type": "API" + } + ] +} diff --git a/tests/integration/fixtures/subscriptions/mock-client-mtls.json b/tests/integration/fixtures/subscriptions/mock-client-mtls.json new file mode 100644 index 00000000..d225612f --- /dev/null +++ b/tests/integration/fixtures/subscriptions/mock-client-mtls.json @@ -0,0 +1,36 @@ +{ + "clientId": "mock-client-mtls", + "subscriptions": [ + { + "messageStatuses": [ + "DELIVERED", + "FAILED" + ], + "subscriptionId": "sub-mtls-msg-001", + "subscriptionType": "MessageStatus", + "targetIds": [ + "target-mtls-001" + ] + } + ], + "targets": [ + { + "apiKey": { + "headerName": "x-api-key", + "headerValue": "REPLACED_BY_TERRAFORM" + }, + "certPinning": { + "enabled": true, + "spkiHash": "REPLACED_BY_TERRAFORM" + }, + "invocationEndpoint": "https://REPLACED_BY_TERRAFORM", + "invocationMethod": "POST", + "invocationRateLimit": 10, + "mtls": { + "enabled": true + }, + "targetId": "target-mtls-001", + "type": "API" + } + ] +} diff --git a/tests/integration/fixtures/subscriptions/mock-client-rate-limit.json b/tests/integration/fixtures/subscriptions/mock-client-rate-limit.json new file mode 100644 index 00000000..80a40e6a --- /dev/null +++ b/tests/integration/fixtures/subscriptions/mock-client-rate-limit.json @@ -0,0 +1,35 @@ +{ + "clientId": "mock-client-rate-limit", + "subscriptions": [ + { + "messageStatuses": [ + "DELIVERED", + "FAILED" + ], + "subscriptionId": "sub-rl-msg-001", + "subscriptionType": "MessageStatus", + "targetIds": [ + "target-rl-001" + ] + } + ], + "targets": [ + { + "apiKey": { + "headerName": "x-api-key", + "headerValue": "REPLACED_BY_TERRAFORM" + }, + "certPinning": { + "enabled": false + }, + "invocationEndpoint": "https://REPLACED_BY_TERRAFORM", + "invocationMethod": "POST", + "invocationRateLimit": 2, + "mtls": { + "enabled": false + }, + "targetId": "target-rl-001", + "type": "API" + } + ] +} diff --git a/tests/integration/helpers/event-factories.ts b/tests/integration/helpers/event-factories.ts index 015bbced..35f7f2e8 100644 --- a/tests/integration/helpers/event-factories.ts +++ b/tests/integration/helpers/event-factories.ts @@ -1,5 +1,6 @@ import type { ChannelStatusData, + ClientCallbackPayload, MessageStatusData, StatusPublishEvent, } from "@nhs-notify-client-callbacks/models"; @@ -17,6 +18,37 @@ type ChannelEventOverrides = { data?: Partial; }; +type DeliveryMessage = { + payload: ClientCallbackPayload; + subscriptions: string[]; + targetId: string; +}; + +export function createDeliveryMessage( + overrides?: Partial, +): DeliveryMessage { + const config = getMockItClientConfig(); + const targetId = + overrides?.targetId ?? config.targets[0]?.targetId ?? "target-001"; + + return { + payload: + overrides?.payload ?? + ({ + data: [ + { + type: "MessageStatus", + attributes: { messageStatus: "delivered" }, + links: { message: "https://api.example.invalid/messages/msg-001" }, + meta: { idempotencyKey: crypto.randomUUID() }, + }, + ], + } as ClientCallbackPayload), + subscriptions: overrides?.subscriptions ?? ["sub-001"], + targetId, + }; +} + export function createMessageStatusPublishEvent( overrides?: MessageEventOverrides, ): StatusPublishEvent { diff --git a/tests/integration/helpers/mock-client-config.ts b/tests/integration/helpers/mock-client-config.ts index eb94974c..a004b4bc 100644 --- a/tests/integration/helpers/mock-client-config.ts +++ b/tests/integration/helpers/mock-client-config.ts @@ -20,6 +20,11 @@ export const CLIENT_FIXTURES = { apiKeyVar: "MOCK_CLIENT_2_API_KEY", applicationIdVar: "MOCK_CLIENT_2_APPLICATION_ID", }, + clientMtls: { + fixture: "mock-client-mtls.json", + apiKeyVar: "MOCK_CLIENT_MTLS_API_KEY", + applicationIdVar: "MOCK_CLIENT_MTLS_APPLICATION_ID", + }, } as const; export type ClientFixtureKey = keyof typeof CLIENT_FIXTURES; diff --git a/tools/client-subscriptions-management/package.json b/tools/client-subscriptions-management/package.json index ec4cb3b3..4d934470 100644 --- a/tools/client-subscriptions-management/package.json +++ b/tools/client-subscriptions-management/package.json @@ -29,6 +29,7 @@ "@aws-sdk/client-sts": "catalog:aws", "@aws-sdk/credential-providers": "catalog:aws", "@nhs-notify-client-callbacks/models": "workspace:*", + "picocolors": "catalog:app", "table": "catalog:app", "yargs": "catalog:app", "zod": "catalog:app" diff --git a/tools/client-subscriptions-management/src/__tests__/domain/client-subscription-builder.test.ts b/tools/client-subscriptions-management/src/__tests__/domain/client-subscription-builder.test.ts index 10fcb111..3b535fbd 100644 --- a/tools/client-subscriptions-management/src/__tests__/domain/client-subscription-builder.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/domain/client-subscription-builder.test.ts @@ -8,6 +8,16 @@ const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i; describe("buildTarget", () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, "warn").mockImplementation(); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + it("builds a target with required fields", () => { const result = buildTarget({ apiEndpoint: "https://example.com/webhook", @@ -22,6 +32,8 @@ describe("buildTarget", () => { invocationMethod: "POST", invocationRateLimit: 10, apiKey: { headerName: "x-api-key", headerValue: "secret" }, + mtls: { enabled: false }, + certPinning: { enabled: false }, }); expect(result.targetId).toMatch(UUID_REGEX); }); @@ -35,6 +47,73 @@ describe("buildTarget", () => { expect(result.apiKey.headerName).toBe("x-api-key"); }); + + it("emits warning when mtls is disabled", () => { + buildTarget({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + rateLimit: 10, + mtls: { enabled: false }, + }); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("mTLS is disabled"), + ); + }); + + it("emits warning when mtls enabled but certPinning disabled", () => { + buildTarget({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + rateLimit: 10, + mtls: { enabled: true }, + certPinning: { enabled: false }, + }); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("certificate pinning is disabled"), + ); + }); + + it("emits warning when certPinning enabled without spkiHash", () => { + buildTarget({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + rateLimit: 10, + mtls: { enabled: true }, + certPinning: { enabled: true }, + }); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("no SPKI hash is stored"), + ); + }); + + it("emits warning when certPinning enabled but mtls disabled", () => { + buildTarget({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + rateLimit: 10, + mtls: { enabled: false }, + certPinning: { enabled: true }, + }); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("mTLS is disabled"), + ); + }); + + it("emits no warnings for fully secure config", () => { + buildTarget({ + apiEndpoint: "https://example.com/webhook", + apiKey: "secret", + rateLimit: 10, + mtls: { enabled: true }, + certPinning: { enabled: true, spkiHash: "abc123" }, + }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); }); describe("buildMessageStatusSubscription", () => { diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-certificate.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-certificate.test.ts new file mode 100644 index 00000000..355c205f --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-certificate.test.ts @@ -0,0 +1,155 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdtempSync, unlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import * as cli from "src/entrypoint/cli/targets-set-certificate"; +import { + captureCliConsoleState, + expectWrappedCliError, + getMockCreateRepository, + resetCliConsoleState, + resetMockCreateRepository, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; +import { + createClientSubscriptionConfig, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; + +const mockGetClientConfig = jest.fn(); +const mockPutClientConfig = jest.fn(); +const mockFormatClientConfig = jest.fn(); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: jest.fn(), +})); +jest.mock("src/format", () => ({ + formatClientConfig: (...args: unknown[]) => mockFormatClientConfig(...args), +})); + +const target = createTarget(); +const config = createClientSubscriptionConfig({ targets: [target] }); +const mockCreateRepository = getMockCreateRepository(); + +let tmpDir: string; +let validPemPath: string; + +beforeAll(() => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "cert-test-")); + + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const keyPem = privateKey.export({ type: "pkcs8", format: "pem" }) as string; + + // eslint-disable-next-line @typescript-eslint/no-require-imports -- openssl needed for self-signed cert generation in tests + const { execSync } = require("node:child_process"); + const certPem = execSync( + `openssl req -x509 -new -key /dev/stdin -days 1 -subj "/CN=test" 2>/dev/null`, + { input: keyPem, encoding: "utf8" }, + ) as string; + + validPemPath = path.join(tmpDir, "test.pem"); + writeFileSync(validPemPath, certPem); +}); + +afterAll(() => { + try { + unlinkSync(validPemPath); + } catch { + // do nothing + } +}); + +describe("targets-set-certificate CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + const baseArgs = [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--target-id", + target.targetId, + ]; + + beforeEach(() => { + mockGetClientConfig.mockReset(); + mockGetClientConfig.mockResolvedValue(config); + mockPutClientConfig.mockReset(); + mockPutClientConfig.mockResolvedValue(config); + mockFormatClientConfig.mockReset(); + mockFormatClientConfig.mockReturnValue("formatted-output"); + resetMockCreateRepository({ + getClientConfig: mockGetClientConfig, + putClientConfig: mockPutClientConfig, + }); + resetCliConsoleState(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("extracts SPKI hash from valid PEM and stores it", async () => { + await cli.main([...baseArgs, "--pem-file", validPemPath]); + + expect(mockPutClientConfig).toHaveBeenCalledWith( + "client-1", + expect.objectContaining({ + targets: [ + expect.objectContaining({ + certPinning: expect.objectContaining({ + spkiHash: expect.stringMatching(/^[A-Za-z0-9+/]{43}=$/), + }), + }), + ], + }), + false, + ); + }); + + it("errors for invalid PEM file", async () => { + const invalidPath = path.join(tmpDir, "invalid.pem"); + writeFileSync(invalidPath, "not-a-pem"); + + resetMockCreateRepository({ + getClientConfig: mockGetClientConfig, + putClientConfig: mockPutClientConfig, + }); + + await cli.main([...baseArgs, "--pem-file", invalidPath]).catch(() => {}); + + expect(mockPutClientConfig).not.toHaveBeenCalled(); + + unlinkSync(invalidPath); + }); + + it("passes dry-run to putClientConfig", async () => { + await cli.main([ + ...baseArgs, + "--pem-file", + validPemPath, + "--dry-run", + "true", + ]); + + expect(mockPutClientConfig).toHaveBeenCalledWith( + "client-1", + expect.any(Object), + true, + ); + }); + + it("handles repository errors in wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, [ + ...baseArgs, + "--pem-file", + validPemPath, + ]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-mtls.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-mtls.test.ts new file mode 100644 index 00000000..0703f9d3 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-mtls.test.ts @@ -0,0 +1,107 @@ +import * as cli from "src/entrypoint/cli/targets-set-mtls"; +import { + captureCliConsoleState, + expectWrappedCliError, + getMockCreateRepository, + resetCliConsoleState, + resetMockCreateRepository, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; +import { + createClientSubscriptionConfig, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; + +const mockGetClientConfig = jest.fn(); +const mockPutClientConfig = jest.fn(); +const mockFormatClientConfig = jest.fn(); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: jest.fn(), +})); +jest.mock("src/format", () => ({ + formatClientConfig: (...args: unknown[]) => mockFormatClientConfig(...args), +})); + +const target = createTarget(); +const config = createClientSubscriptionConfig({ targets: [target] }); +const mockCreateRepository = getMockCreateRepository(); + +describe("targets-set-mtls CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + const baseArgs = [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--target-id", + target.targetId, + ]; + + beforeEach(() => { + mockGetClientConfig.mockReset(); + mockGetClientConfig.mockResolvedValue(config); + mockPutClientConfig.mockReset(); + mockPutClientConfig.mockResolvedValue(config); + mockFormatClientConfig.mockReset(); + mockFormatClientConfig.mockReturnValue("formatted-output"); + resetMockCreateRepository({ + getClientConfig: mockGetClientConfig, + putClientConfig: mockPutClientConfig, + }); + resetCliConsoleState(); + console.warn = jest.fn(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("enables mTLS with --enable flag", async () => { + await cli.main([...baseArgs, "--enable"]); + + expect(mockPutClientConfig).toHaveBeenCalledWith( + "client-1", + expect.objectContaining({ + targets: [expect.objectContaining({ mtls: { enabled: true } })], + }), + false, + ); + }); + + it("disables mTLS with --disable flag and emits ANSI warning", async () => { + await cli.main([...baseArgs, "--disable"]); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("Disabling mTLS"), + ); + expect(mockPutClientConfig).toHaveBeenCalledWith( + "client-1", + expect.objectContaining({ + targets: [expect.objectContaining({ mtls: { enabled: false } })], + }), + false, + ); + }); + + it("passes dry-run to putClientConfig", async () => { + await cli.main([...baseArgs, "--enable", "--dry-run", "true"]); + + expect(mockPutClientConfig).toHaveBeenCalledWith( + "client-1", + expect.any(Object), + true, + ); + }); + + it("handles errors in wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, [...baseArgs, "--enable"]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-pinning.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-pinning.test.ts new file mode 100644 index 00000000..3d912259 --- /dev/null +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-pinning.test.ts @@ -0,0 +1,133 @@ +import * as cli from "src/entrypoint/cli/targets-set-pinning"; +import { + captureCliConsoleState, + expectWrappedCliError, + getMockCreateRepository, + resetCliConsoleState, + resetMockCreateRepository, + restoreCliConsoleState, +} from "src/__tests__/entrypoint/cli/test-utils"; +import { + createClientSubscriptionConfig, + createTarget, +} from "src/__tests__/helpers/client-subscription-fixtures"; + +const mockGetClientConfig = jest.fn(); +const mockPutClientConfig = jest.fn(); +const mockFormatClientConfig = jest.fn(); + +jest.mock("src/entrypoint/cli/helper", () => ({ + ...jest.requireActual("src/entrypoint/cli/helper"), + createRepository: jest.fn(), +})); +jest.mock("src/format", () => ({ + formatClientConfig: (...args: unknown[]) => mockFormatClientConfig(...args), +})); + +const target = createTarget({ + certPinning: { enabled: true, spkiHash: "existing-hash" }, +}); +const config = createClientSubscriptionConfig({ targets: [target] }); +const mockCreateRepository = getMockCreateRepository(); + +describe("targets-set-pinning CLI", () => { + const originalCliConsoleState = captureCliConsoleState(); + + const baseArgs = [ + "node", + "script", + "--client-id", + "client-1", + "--bucket-name", + "bucket-1", + "--target-id", + target.targetId, + ]; + + beforeEach(() => { + mockGetClientConfig.mockReset(); + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + targets: [ + createTarget({ + certPinning: { enabled: true, spkiHash: "existing-hash" }, + }), + ], + }), + ); + mockPutClientConfig.mockReset(); + mockPutClientConfig.mockResolvedValue(config); + mockFormatClientConfig.mockReset(); + mockFormatClientConfig.mockReturnValue("formatted-output"); + resetMockCreateRepository({ + getClientConfig: mockGetClientConfig, + putClientConfig: mockPutClientConfig, + }); + resetCliConsoleState(); + console.warn = jest.fn(); + }); + + afterAll(() => { + restoreCliConsoleState(originalCliConsoleState); + }); + + it("enables certificate pinning with --enable flag", async () => { + await cli.main([...baseArgs, "--enable"]); + + expect(mockPutClientConfig).toHaveBeenCalledWith( + "client-1", + expect.objectContaining({ + targets: [ + expect.objectContaining({ + certPinning: { enabled: true, spkiHash: "existing-hash" }, + }), + ], + }), + false, + ); + }); + + it("disables pinning with --disable flag and emits ANSI warning", async () => { + await cli.main([...baseArgs, "--disable"]); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("Disabling certificate pinning"), + ); + expect(mockPutClientConfig).toHaveBeenCalledWith( + "client-1", + expect.objectContaining({ + targets: [ + expect.objectContaining({ + certPinning: { enabled: false, spkiHash: "existing-hash" }, + }), + ], + }), + false, + ); + }); + + it("preserves existing spkiHash when disabling", async () => { + await cli.main([...baseArgs, "--disable"]); + + const putCall = mockPutClientConfig.mock.calls[0]; + const updatedTarget = putCall[1].targets[0]; + expect(updatedTarget.certPinning.spkiHash).toBe("existing-hash"); + }); + + it("passes dry-run to putClientConfig", async () => { + await cli.main([...baseArgs, "--enable", "--dry-run", "true"]); + + expect(mockPutClientConfig).toHaveBeenCalledWith( + "client-1", + expect.any(Object), + true, + ); + }); + + it("handles errors in wrapped CLI", async () => { + expect.hasAssertions(); + mockCreateRepository.mockRejectedValue(new Error("Boom")); + + await expectWrappedCliError(cli.main, [...baseArgs, "--enable"]); + }); +}); diff --git a/tools/client-subscriptions-management/src/__tests__/helpers/client-subscription-fixtures.ts b/tools/client-subscriptions-management/src/__tests__/helpers/client-subscription-fixtures.ts index de12586e..ee41bd09 100644 --- a/tools/client-subscriptions-management/src/__tests__/helpers/client-subscription-fixtures.ts +++ b/tools/client-subscriptions-management/src/__tests__/helpers/client-subscription-fixtures.ts @@ -24,6 +24,8 @@ export const createTarget = ( headerValue: "secret", ...overrides.apiKey, }, + mtls: { enabled: false }, + certPinning: { enabled: false }, ...overrides, }); diff --git a/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts index f91ee5a4..43edbc5a 100644 --- a/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts +++ b/tools/client-subscriptions-management/src/domain/client-subscription-builder.ts @@ -7,12 +7,15 @@ import type { MessageStatusSubscriptionConfiguration, SupplierStatus, } from "@nhs-notify-client-callbacks/models"; +import pc from "picocolors"; export type BuildTargetArgs = { apiEndpoint: string; apiKey: string; apiKeyHeaderName?: string; rateLimit: number; + mtls?: { enabled: boolean }; + certPinning?: { enabled: boolean; spkiHash?: string }; }; export type BuildMessageStatusSubscriptionArgs = { @@ -30,6 +33,31 @@ export type BuildChannelStatusSubscriptionArgs = { }; export function buildTarget(args: BuildTargetArgs): CallbackTarget { + const mtls = args.mtls ?? { enabled: false }; + const certPinning = args.certPinning ?? { enabled: false }; + + const warnings: string[] = []; + + if (!mtls.enabled) { + warnings.push("mTLS is disabled — callbacks will not use mutual TLS"); + } + + if (mtls.enabled && !certPinning.enabled) { + warnings.push("mTLS is enabled but certificate pinning is disabled"); + } + + if (certPinning.enabled && !certPinning.spkiHash) { + warnings.push("Certificate pinning is enabled but no SPKI hash is stored"); + } + + if (!mtls.enabled && certPinning.enabled) { + warnings.push("Certificate pinning is enabled but mTLS is disabled"); + } + + for (const warning of warnings) { + console.warn(pc.bold(pc.red(`WARNING: ${warning}`))); + } + return { targetId: crypto.randomUUID(), type: "API", @@ -40,6 +68,8 @@ export function buildTarget(args: BuildTargetArgs): CallbackTarget { headerName: args.apiKeyHeaderName ?? "x-api-key", headerValue: args.apiKey, }, + mtls, + certPinning, }; } diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts index c0d13554..2e5dc849 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/clients-put.ts @@ -56,7 +56,6 @@ export const handler: CliCommand["handler"] = async (argv) => { return; } - // Safe as this is an internal tool and this CLI option we are expecting the user will run locally and manually // eslint-disable-next-line security/detect-non-literal-fs-filename const rawJson = argv.json ?? readFileSync(argv.file!, "utf8"); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-set-certificate.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-set-certificate.ts new file mode 100644 index 00000000..ea32f8b9 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-set-certificate.ts @@ -0,0 +1,94 @@ +import { X509Certificate, createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import type { Argv } from "yargs"; +import { + type CliCommand, + type ClientCliArgs, + type WriteCliArgs, + clientIdOption, + commonOptions, + createRepository, + runCommand, + writeOptions, +} from "src/entrypoint/cli/helper"; +import { formatClientConfig } from "src/format"; + +type TargetsSetCertificateArgs = ClientCliArgs & + WriteCliArgs & { + "target-id": string; + "pem-file": string; + }; + +export const builder = (yargs: Argv) => + yargs.options({ + ...commonOptions, + ...clientIdOption, + ...writeOptions, + "target-id": { + type: "string", + demandOption: true, + description: "Target identifier to update", + }, + "pem-file": { + type: "string", + demandOption: true, + description: "Path to PEM certificate file", + }, + }); + +function extractSpkiHash(pemPath: string): string { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- path is provided directly by the operator via CLI arg + const pemBuffer = readFileSync(pemPath); + const x509 = new X509Certificate(pemBuffer); + const spkiDer = x509.publicKey.export({ + type: "spki", + format: "der", + }) as Buffer; + return createHash("sha256").update(spkiDer).digest("base64"); +} + +export const handler: CliCommand["handler"] = async ( + argv, +) => { + const spkiHash = extractSpkiHash(argv["pem-file"]); + console.log(`Extracted SPKI hash: ${spkiHash}`); + + const repository = await createRepository(argv); + const config = await repository.getClientConfig(argv["client-id"]); + + if (!config) { + throw new Error(`No configuration found for client: ${argv["client-id"]}`); + } + + const target = config.targets.find((t) => t.targetId === argv["target-id"]); + + if (!target) { + throw new Error( + `Target '${argv["target-id"]}' not found for client '${argv["client-id"]}'`, + ); + } + + target.certPinning = { + ...target.certPinning, + spkiHash, + }; + + const result = await repository.putClientConfig( + argv["client-id"], + config, + argv["dry-run"], + ); + console.log("Certificate SPKI hash stored successfully"); + console.log(formatClientConfig(result)); +}; + +export const command: CliCommand = { + command: "targets-set-certificate", + describe: "Extract and store SPKI hash from a PEM certificate for a target", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-set-mtls.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-set-mtls.ts new file mode 100644 index 00000000..b143b3d8 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-set-mtls.ts @@ -0,0 +1,101 @@ +import type { Argv } from "yargs"; +import pc from "picocolors"; +import { + type CliCommand, + type ClientCliArgs, + type WriteCliArgs, + clientIdOption, + commonOptions, + createRepository, + runCommand, + writeOptions, +} from "src/entrypoint/cli/helper"; +import { formatClientConfig } from "src/format"; + +type TargetsSetMtlsArgs = ClientCliArgs & + WriteCliArgs & { + "target-id": string; + enable?: boolean; + disable?: boolean; + }; + +export const builder = (yargs: Argv) => + yargs + .options({ + ...commonOptions, + ...clientIdOption, + ...writeOptions, + "target-id": { + type: "string", + demandOption: true, + description: "Target identifier to update", + }, + enable: { + type: "boolean", + description: "Enable mTLS for this target", + conflicts: "disable", + }, + disable: { + type: "boolean", + description: "Disable mTLS for this target", + conflicts: "enable", + }, + }) + .check((argv) => { + if (!argv.enable && !argv.disable) { + throw new Error("Specify either --enable or --disable"); + } + return true; + }); + +export const handler: CliCommand["handler"] = async ( + argv, +) => { + const enabled = argv.enable === true; + + if (!enabled) { + console.warn( + pc.bold( + pc.red("WARNING: Disabling mTLS — callbacks will not use mutual TLS"), + ), + ); + } + + const repository = await createRepository(argv); + const config = await repository.getClientConfig(argv["client-id"]); + + if (!config) { + throw new Error(`No configuration found for client: ${argv["client-id"]}`); + } + + const target = config.targets.find((t) => t.targetId === argv["target-id"]); + + if (!target) { + throw new Error( + `Target '${argv["target-id"]}' not found for client '${argv["client-id"]}'`, + ); + } + + target.mtls = { enabled }; + + const result = await repository.putClientConfig( + argv["client-id"], + config, + argv["dry-run"], + ); + console.log( + `mTLS ${enabled ? "enabled" : "disabled"} for target ${argv["target-id"]}`, + ); + console.log(formatClientConfig(result)); +}; + +export const command: CliCommand = { + command: "targets-set-mtls", + describe: "Enable or disable mTLS for a callback target", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-set-pinning.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-set-pinning.ts new file mode 100644 index 00000000..00220e45 --- /dev/null +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-set-pinning.ts @@ -0,0 +1,100 @@ +import type { Argv } from "yargs"; +import pc from "picocolors"; +import { + type CliCommand, + type ClientCliArgs, + type WriteCliArgs, + clientIdOption, + commonOptions, + createRepository, + runCommand, + writeOptions, +} from "src/entrypoint/cli/helper"; +import { formatClientConfig } from "src/format"; + +type TargetsSetPinningArgs = ClientCliArgs & + WriteCliArgs & { + "target-id": string; + enable?: boolean; + disable?: boolean; + }; + +export const builder = (yargs: Argv) => + yargs + .options({ + ...commonOptions, + ...clientIdOption, + ...writeOptions, + "target-id": { + type: "string", + demandOption: true, + description: "Target identifier to update", + }, + enable: { + type: "boolean", + description: "Enable certificate pinning for this target", + conflicts: "disable", + }, + disable: { + type: "boolean", + description: "Disable certificate pinning for this target", + conflicts: "enable", + }, + }) + .check((argv) => { + if (!argv.enable && !argv.disable) { + throw new Error("Specify either --enable or --disable"); + } + return true; + }); + +export const handler: CliCommand["handler"] = async ( + argv, +) => { + const enabled = argv.enable === true; + + if (!enabled) { + console.warn(pc.bold(pc.red("WARNING: Disabling certificate pinning"))); + } + + const repository = await createRepository(argv); + const config = await repository.getClientConfig(argv["client-id"]); + + if (!config) { + throw new Error(`No configuration found for client: ${argv["client-id"]}`); + } + + const target = config.targets.find((t) => t.targetId === argv["target-id"]); + + if (!target) { + throw new Error( + `Target '${argv["target-id"]}' not found for client '${argv["client-id"]}'`, + ); + } + + target.certPinning = { + ...target.certPinning, + enabled, + }; + + const result = await repository.putClientConfig( + argv["client-id"], + config, + argv["dry-run"], + ); + console.log( + `Certificate pinning ${enabled ? "enabled" : "disabled"} for target ${argv["target-id"]}`, + ); + console.log(formatClientConfig(result)); +}; + +export const command: CliCommand = { + command: "targets-set-pinning", + describe: "Enable or disable certificate pinning for a callback target", + builder, + handler, +}; + +export async function main(args: string[] = process.argv) { + await runCommand(command, args); +} From e2ea3054557aff380bfab23d3560d2a230ea5995 Mon Sep 17 00:00:00 2001 From: Rhys Cox Date: Wed, 15 Apr 2026 12:39:49 +0100 Subject: [PATCH 2/2] CCM-16073 - PR feedback --- .../cloudwatch_event_rule_per_subscription.tf | 1 + .../src/__tests__/config-loader.test.ts | 24 ++++++++++----- .../__tests__/ssm-applications-map.test.ts | 12 ++++---- .../src/__tests__/tls-agent-factory.test.ts | 30 +++++++++++-------- .../src/services/config-loader.ts | 20 ++++++++----- .../src/services/delivery/https-client.ts | 4 +-- .../services/delivery/tls-agent-factory.ts | 15 ++++++---- .../src/services/ssm-applications-map.ts | 14 ++++----- .../cli/targets-set-pinning.test.ts | 19 ++++++++++++ .../src/entrypoint/cli/targets-set-pinning.ts | 6 ++++ 10 files changed, 99 insertions(+), 46 deletions(-) diff --git a/infrastructure/terraform/modules/client-delivery/cloudwatch_event_rule_per_subscription.tf b/infrastructure/terraform/modules/client-delivery/cloudwatch_event_rule_per_subscription.tf index de523ccd..82c35f25 100644 --- a/infrastructure/terraform/modules/client-delivery/cloudwatch_event_rule_per_subscription.tf +++ b/infrastructure/terraform/modules/client-delivery/cloudwatch_event_rule_per_subscription.tf @@ -21,6 +21,7 @@ resource "aws_cloudwatch_event_target" "per_subscription_target" { arn = module.sqs_delivery.sqs_queue_arn target_id = "${local.client_prefix}-${each.value.target_id}" event_bus_name = var.client_bus_name + role_arn = aws_iam_role.eventbridge_sqs_target.arn sqs_target { message_group_id = null diff --git a/lambdas/https-client-lambda/src/__tests__/config-loader.test.ts b/lambdas/https-client-lambda/src/__tests__/config-loader.test.ts index 4297e85e..a8ccdfb4 100644 --- a/lambdas/https-client-lambda/src/__tests__/config-loader.test.ts +++ b/lambdas/https-client-lambda/src/__tests__/config-loader.test.ts @@ -22,8 +22,9 @@ jest.mock("services/logger", () => ({ }, })); -process.env.CLIENT_CONFIG_BUCKET = "test-bucket"; -process.env.CONFIG_CACHE_TTL_MS = "1000"; +process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = "test-bucket"; +process.env.CLIENT_SUBSCRIPTION_CONFIG_PREFIX = "client_subscriptions/"; +process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS = "1"; const VALID_TARGET = { targetId: "target-1", @@ -64,6 +65,15 @@ describe("loadTargetConfig", () => { expect(mockS3Send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand); }); + it("uses CLIENT_SUBSCRIPTION_CONFIG_PREFIX for the S3 key", async () => { + mockS3Send.mockResolvedValue(makeS3Response(VALID_CONFIG)); + + await loadTargetConfig("client-1", "target-1"); + + const command: GetObjectCommand = mockS3Send.mock.calls[0][0]; + expect(command.input.Key).toBe("client_subscriptions/client-1.json"); + }); + it("rejects config missing required field", async () => { // eslint-disable-next-line @typescript-eslint/naming-convention, sonarjs/no-unused-vars -- destructuring to exclude mtls const { mtls: _unusedMtls, ...targetWithoutMtls } = VALID_TARGET; @@ -104,10 +114,10 @@ describe("loadTargetConfig", () => { jest.useRealTimers(); }); - it("throws when CLIENT_CONFIG_BUCKET is not set", async () => { + it("throws when CLIENT_SUBSCRIPTION_CONFIG_BUCKET is not set", async () => { let loadFn: typeof loadTargetConfig; - const saved = process.env.CLIENT_CONFIG_BUCKET; - delete process.env.CLIENT_CONFIG_BUCKET; + const saved = process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; + delete process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports -- jest.isolateModules requires synchronous require @@ -115,10 +125,10 @@ describe("loadTargetConfig", () => { }); await expect(loadFn!("client-1", "target-1")).rejects.toThrow( - "CLIENT_CONFIG_BUCKET is required", + "CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required", ); - process.env.CLIENT_CONFIG_BUCKET = saved; + process.env.CLIENT_SUBSCRIPTION_CONFIG_BUCKET = saved; }); it("throws when S3 response body is empty", async () => { diff --git a/lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts b/lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts index 06485ae9..40592928 100644 --- a/lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts +++ b/lambdas/https-client-lambda/src/__tests__/ssm-applications-map.test.ts @@ -22,7 +22,7 @@ jest.mock("services/logger", () => ({ }, })); -process.env.APPLICATIONS_MAP_PARAMETER_NAME = "/test/applications-map"; +process.env.APPLICATIONS_MAP_PARAMETER = "/test/applications-map"; describe("getApplicationId", () => { beforeEach(() => { @@ -67,10 +67,10 @@ describe("getApplicationId", () => { ); }); - it("throws when APPLICATIONS_MAP_PARAMETER_NAME is not set", async () => { + it("throws when APPLICATIONS_MAP_PARAMETER is not set", async () => { let getFn: typeof getApplicationId; - const saved = process.env.APPLICATIONS_MAP_PARAMETER_NAME; - delete process.env.APPLICATIONS_MAP_PARAMETER_NAME; + const saved = process.env.APPLICATIONS_MAP_PARAMETER; + delete process.env.APPLICATIONS_MAP_PARAMETER; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports -- jest.isolateModules requires synchronous require @@ -78,10 +78,10 @@ describe("getApplicationId", () => { }); await expect(getFn!("client-1")).rejects.toThrow( - "APPLICATIONS_MAP_PARAMETER_NAME is required", + "APPLICATIONS_MAP_PARAMETER is required", ); - process.env.APPLICATIONS_MAP_PARAMETER_NAME = saved; + process.env.APPLICATIONS_MAP_PARAMETER = saved; }); it("throws when SSM parameter value is empty", async () => { diff --git a/lambdas/https-client-lambda/src/__tests__/tls-agent-factory.test.ts b/lambdas/https-client-lambda/src/__tests__/tls-agent-factory.test.ts index 231b9cb2..94261cbe 100644 --- a/lambdas/https-client-lambda/src/__tests__/tls-agent-factory.test.ts +++ b/lambdas/https-client-lambda/src/__tests__/tls-agent-factory.test.ts @@ -102,10 +102,11 @@ describe("tls-agent-factory", () => { }); it("builds agent without key and cert when mtls is disabled", async () => { - mockS3PemResponse(COMBINED_PEM); const agent = await buildAgent(createTarget({ mtls: { enabled: false } })); expect(agent).toBeDefined(); + expect(mockS3Send).not.toHaveBeenCalled(); + expect(mockSecretsManagerSend).not.toHaveBeenCalled(); }); it("loads test CA when MTLS_TEST_CA_S3_KEY is set", async () => { @@ -164,7 +165,7 @@ describe("tls-agent-factory", () => { it("caches cert material on subsequent calls", async () => { mockS3PemResponse(COMBINED_PEM); - const target = createTarget({ mtls: { enabled: false } }); + const target = createTarget({ mtls: { enabled: true } }); await buildAgent(target); await buildAgent(target); @@ -182,7 +183,7 @@ describe("tls-agent-factory", () => { it("resets cached material via resetCache", async () => { mockS3PemResponse(COMBINED_PEM); - const target = createTarget({ mtls: { enabled: false } }); + const target = createTarget({ mtls: { enabled: true } }); await buildAgent(target); resetCache(); @@ -322,17 +323,22 @@ describe("tls-agent-factory", () => { expect(result!.message).toContain("does not match"); }); - it("throws when MTLS_CERT_SECRET_ARN is falsy in loadFromSecretsManager path", async () => { - process.env.MTLS_CERT_SECRET_ARN = ""; - jest.resetModules(); - // @ts-expect-error -- modulePaths resolves at runtime - const mod = await import("services/delivery/tls-agent-factory"); + it("does not load cert material when mtls is disabled", async () => { + const agent = await buildAgent(createTarget({ mtls: { enabled: false } })); - mockS3PemResponse(COMBINED_PEM); + expect(agent).toBeDefined(); + expect(mockS3Send).not.toHaveBeenCalled(); + expect(mockSecretsManagerSend).not.toHaveBeenCalled(); + }); - const agent = await mod.buildAgent( - createTarget({ mtls: { enabled: false } }), + it("throws when certPinning.enabled is true but spkiHash is missing", async () => { + const target = createTarget({ + certPinning: { enabled: true }, + }); + + await expect(buildAgent(target)).rejects.toThrow( + "certPinning.spkiHash is required when certPinning is enabled", ); - expect(agent).toBeDefined(); + expect(mockS3Send).not.toHaveBeenCalled(); }); }); diff --git a/lambdas/https-client-lambda/src/services/config-loader.ts b/lambdas/https-client-lambda/src/services/config-loader.ts index bd17eac9..c8f9e714 100644 --- a/lambdas/https-client-lambda/src/services/config-loader.ts +++ b/lambdas/https-client-lambda/src/services/config-loader.ts @@ -11,8 +11,9 @@ let cache: ConfigCache | undefined; function getCache(): ConfigCache { if (!cache) { - const ttl = Number(process.env.CONFIG_CACHE_TTL_MS) || 300_000; - cache = new ConfigCache(ttl); + const ttlSeconds = + Number(process.env.CLIENT_SUBSCRIPTION_CACHE_TTL_SECONDS) || 300; + cache = new ConfigCache(ttlSeconds * 1000); } return cache; } @@ -28,15 +29,20 @@ export async function loadTargetConfig( let clientConfig = getCache().get(clientId); if (!clientConfig) { - const { CLIENT_CONFIG_BUCKET } = process.env; - if (!CLIENT_CONFIG_BUCKET) { - throw new Error("CLIENT_CONFIG_BUCKET is required"); + const { + CLIENT_SUBSCRIPTION_CONFIG_BUCKET, + CLIENT_SUBSCRIPTION_CONFIG_PREFIX, + } = process.env; + if (!CLIENT_SUBSCRIPTION_CONFIG_BUCKET) { + throw new Error("CLIENT_SUBSCRIPTION_CONFIG_BUCKET is required"); } + const prefix = CLIENT_SUBSCRIPTION_CONFIG_PREFIX ?? "client_subscriptions/"; + const response = await s3Client.send( new GetObjectCommand({ - Bucket: CLIENT_CONFIG_BUCKET, - Key: `clients/${clientId}.json`, + Bucket: CLIENT_SUBSCRIPTION_CONFIG_BUCKET, + Key: `${prefix}${clientId}.json`, }), ); diff --git a/lambdas/https-client-lambda/src/services/delivery/https-client.ts b/lambdas/https-client-lambda/src/services/delivery/https-client.ts index b9d6efa1..1ffcb725 100644 --- a/lambdas/https-client-lambda/src/services/delivery/https-client.ts +++ b/lambdas/https-client-lambda/src/services/delivery/https-client.ts @@ -28,11 +28,11 @@ export function deliverPayload( hostname: url.hostname, port: url.port || 443, path: url.pathname + url.search, - method: "POST", + method: target.invocationMethod, agent, headers: { "Content-Type": "application/json", - "X-Notify-Signature": signatureHeader, + "x-hmac-sha256-signature": signatureHeader, [target.apiKey.headerName]: target.apiKey.headerValue, }, }, diff --git a/lambdas/https-client-lambda/src/services/delivery/tls-agent-factory.ts b/lambdas/https-client-lambda/src/services/delivery/tls-agent-factory.ts index 0b5f9dd0..6bd2a271 100644 --- a/lambdas/https-client-lambda/src/services/delivery/tls-agent-factory.ts +++ b/lambdas/https-client-lambda/src/services/delivery/tls-agent-factory.ts @@ -136,24 +136,29 @@ const PERMANENT_TLS_ERROR_CODES = new Set([ export { PERMANENT_TLS_ERROR_CODES }; export async function buildAgent(target: CallbackTarget): Promise { - const material = await getMaterial(); - const agentOptions: Record = { keepAlive: false, }; if (target.mtls.enabled) { + const material = await getMaterial(); agentOptions.key = material.key; agentOptions.cert = material.cert; - } - if (material.ca) { - agentOptions.ca = material.ca; + if (material.ca) { + agentOptions.ca = material.ca; + } } if (target.certPinning.enabled) { const expectedHash = target.certPinning.spkiHash; + if (!expectedHash) { + throw new Error( + `certPinning.spkiHash is required when certPinning is enabled for target '${target.targetId}'`, + ); + } + /* eslint-disable sonarjs/function-return-type -- checkServerIdentity requires Error|undefined return */ agentOptions.checkServerIdentity = ( hostname: string, diff --git a/lambdas/https-client-lambda/src/services/ssm-applications-map.ts b/lambdas/https-client-lambda/src/services/ssm-applications-map.ts index a69e7f65..73f3ea61 100644 --- a/lambdas/https-client-lambda/src/services/ssm-applications-map.ts +++ b/lambdas/https-client-lambda/src/services/ssm-applications-map.ts @@ -10,21 +10,21 @@ async function loadMap(): Promise> { return cachedMap; } - const { APPLICATIONS_MAP_PARAMETER_NAME } = process.env; - if (!APPLICATIONS_MAP_PARAMETER_NAME) { - throw new Error("APPLICATIONS_MAP_PARAMETER_NAME is required"); + const { APPLICATIONS_MAP_PARAMETER } = process.env; + if (!APPLICATIONS_MAP_PARAMETER) { + throw new Error("APPLICATIONS_MAP_PARAMETER is required"); } const response = await ssmClient.send( new GetParameterCommand({ - Name: APPLICATIONS_MAP_PARAMETER_NAME, + Name: APPLICATIONS_MAP_PARAMETER, WithDecryption: true, }), ); if (!response.Parameter?.Value) { throw new Error( - `SSM parameter '${APPLICATIONS_MAP_PARAMETER_NAME}' not found or has no value`, + `SSM parameter '${APPLICATIONS_MAP_PARAMETER}' not found or has no value`, ); } @@ -33,13 +33,13 @@ async function loadMap(): Promise> { parsed = JSON.parse(response.Parameter.Value) as Record; } catch { throw new Error( - `SSM parameter '${APPLICATIONS_MAP_PARAMETER_NAME}' contains invalid JSON`, + `SSM parameter '${APPLICATIONS_MAP_PARAMETER}' contains invalid JSON`, ); } cachedMap = new Map(Object.entries(parsed)); logger.info("Applications map loaded from SSM", { - parameterName: APPLICATIONS_MAP_PARAMETER_NAME, + parameterName: APPLICATIONS_MAP_PARAMETER, }); return cachedMap; } diff --git a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-pinning.test.ts b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-pinning.test.ts index 3d912259..f2cfd9ea 100644 --- a/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-pinning.test.ts +++ b/tools/client-subscriptions-management/src/__tests__/entrypoint/cli/targets-set-pinning.test.ts @@ -130,4 +130,23 @@ describe("targets-set-pinning CLI", () => { await expectWrappedCliError(cli.main, [...baseArgs, "--enable"]); }); + + it("throws when enabling pinning but target has no spkiHash", async () => { + expect.hasAssertions(); + mockGetClientConfig.mockResolvedValue( + createClientSubscriptionConfig({ + targets: [ + createTarget({ + certPinning: { enabled: false }, + }), + ], + }), + ); + + await expectWrappedCliError( + cli.main, + [...baseArgs, "--enable"], + `Target '${target.targetId}' has no SPKI hash stored. Run 'targets-set-certificate' first to configure a certificate hash before enabling pinning.`, + ); + }); }); diff --git a/tools/client-subscriptions-management/src/entrypoint/cli/targets-set-pinning.ts b/tools/client-subscriptions-management/src/entrypoint/cli/targets-set-pinning.ts index 00220e45..1e6a6c85 100644 --- a/tools/client-subscriptions-management/src/entrypoint/cli/targets-set-pinning.ts +++ b/tools/client-subscriptions-management/src/entrypoint/cli/targets-set-pinning.ts @@ -72,6 +72,12 @@ export const handler: CliCommand["handler"] = async ( ); } + if (enabled && !target.certPinning.spkiHash) { + throw new Error( + `Target '${argv["target-id"]}' has no SPKI hash stored. Run 'targets-set-certificate' first to configure a certificate hash before enabling pinning.`, + ); + } + target.certPinning = { ...target.certPinning, enabled,