diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index 3cc47f95..25f460fb 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -31,7 +31,7 @@ No requirements. | [pdm\_use\_non\_mock\_token](#input\_pdm\_use\_non\_mock\_token) | Whether to use the shared APIM access token from SSM (/component/environment/apim/access\_token) instead of the mock token | `bool` | `false` | no | | [project](#input\_project) | The name of the tfscaffold project | `string` | n/a | yes | | [queue\_batch\_size](#input\_queue\_batch\_size) | maximum number of queue items to process | `number` | `10` | no | -| [queue\_batch\_window\_seconds](#input\_queue\_batch\_window\_seconds) | maximum time in seconds between processing events | `number` | `10` | no | +| [queue\_batch\_window\_seconds](#input\_queue\_batch\_window\_seconds) | maximum time in seconds between processing events | `number` | `1` | no | | [region](#input\_region) | The AWS Region | `string` | n/a | yes | | [shared\_infra\_account\_id](#input\_shared\_infra\_account\_id) | The AWS Shared Infra Account ID (numeric) | `string` | n/a | yes | | [ttl\_poll\_schedule](#input\_ttl\_poll\_schedule) | Schedule to poll for any overdue TTL records | `string` | `"rate(10 minutes)"` | no | @@ -44,11 +44,13 @@ No requirements. | [lambda\_lambda\_apim\_refresh\_token](#module\_lambda\_lambda\_apim\_refresh\_token) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [mesh\_poll](#module\_mesh\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [pdm\_mock](#module\_pdm\_mock) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [pdm\_poll](#module\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [pdm\_uploader](#module\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | | [s3bucket\_letters](#module\_s3bucket\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | | [s3bucket\_static\_assets](#module\_s3bucket\_static\_assets) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | | [sqs\_event\_publisher\_errors](#module\_sqs\_event\_publisher\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | +| [sqs\_pdm\_poll](#module\_sqs\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_pdm\_uploader](#module\_sqs\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_ttl](#module\_sqs\_ttl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_ttl\_handle\_expiry\_errors](#module\_sqs\_ttl\_handle\_expiry\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | diff --git a/infrastructure/terraform/components/dl/aws_api_gateway_deployment_pdm_mock.tf b/infrastructure/terraform/components/dl/aws_api_gateway_deployment_pdm_mock.tf index 6468311b..c6c39b1b 100644 --- a/infrastructure/terraform/components/dl/aws_api_gateway_deployment_pdm_mock.tf +++ b/infrastructure/terraform/components/dl/aws_api_gateway_deployment_pdm_mock.tf @@ -16,7 +16,9 @@ resource "aws_api_gateway_deployment" "pdm_mock" { aws_api_gateway_resource.document_reference[0].id, aws_api_gateway_resource.document_reference_id[0].id, aws_api_gateway_method.create_document_reference[0].id, + aws_api_gateway_method.create_document_reference[0].authorization, aws_api_gateway_method.get_document_reference[0].id, + aws_api_gateway_method.get_document_reference[0].authorization, aws_api_gateway_integration.create_document_reference[0].id, aws_api_gateway_integration.get_document_reference[0].id, ])) diff --git a/infrastructure/terraform/components/dl/aws_api_gateway_method_create_document_reference.tf b/infrastructure/terraform/components/dl/aws_api_gateway_method_create_document_reference.tf index 5058b284..c2d1d30c 100644 --- a/infrastructure/terraform/components/dl/aws_api_gateway_method_create_document_reference.tf +++ b/infrastructure/terraform/components/dl/aws_api_gateway_method_create_document_reference.tf @@ -4,5 +4,5 @@ resource "aws_api_gateway_method" "create_document_reference" { rest_api_id = aws_api_gateway_rest_api.pdm_mock[0].id resource_id = aws_api_gateway_resource.document_reference[0].id http_method = "POST" - authorization = "AWS_IAM" + authorization = "NONE" } diff --git a/infrastructure/terraform/components/dl/aws_api_gateway_method_get_document_reference.tf b/infrastructure/terraform/components/dl/aws_api_gateway_method_get_document_reference.tf index 7d107af6..56ac1b43 100644 --- a/infrastructure/terraform/components/dl/aws_api_gateway_method_get_document_reference.tf +++ b/infrastructure/terraform/components/dl/aws_api_gateway_method_get_document_reference.tf @@ -4,5 +4,5 @@ resource "aws_api_gateway_method" "get_document_reference" { rest_api_id = aws_api_gateway_rest_api.pdm_mock[0].id resource_id = aws_api_gateway_resource.document_reference_id[0].id http_method = "GET" - authorization = "AWS_IAM" + authorization = "NONE" } diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf new file mode 100644 index 00000000..9c5394fc --- /dev/null +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_submitted.tf @@ -0,0 +1,19 @@ +resource "aws_cloudwatch_event_rule" "pdm_resource_submitted" { + name = "${local.csi}-pdm-resource-submitted" + description = "PDM resource submitted event rule" + event_bus_name = aws_cloudwatch_event_bus.main.name + + event_pattern = jsonencode({ + "detail" : { + "type" : [ + "uk.nhs.notify.digital.letters.pdm.resource.submitted.v1" + ] + } + }) +} + +resource "aws_cloudwatch_event_target" "pdm_resource_submitted_pdm_poll" { + rule = aws_cloudwatch_event_rule.pdm_resource_submitted.name + arn = module.sqs_pdm_poll.sqs_queue_arn + event_bus_name = aws_cloudwatch_event_bus.main.name +} diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf new file mode 100644 index 00000000..ed63ebbe --- /dev/null +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_pdm_resource_unavailable.tf @@ -0,0 +1,19 @@ +resource "aws_cloudwatch_event_rule" "pdm_resource_unavailable" { + name = "${local.csi}-pdm-resource-unavailable" + description = "PDM resource unavailable event rule" + event_bus_name = aws_cloudwatch_event_bus.main.name + + event_pattern = jsonencode({ + "detail" : { + "type" : [ + "uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1" + ] + } + }) +} + +resource "aws_cloudwatch_event_target" "pdm_resource_unavailable_pdm_poll" { + rule = aws_cloudwatch_event_rule.pdm_resource_unavailable.name + arn = module.sqs_pdm_poll.sqs_queue_arn + event_bus_name = aws_cloudwatch_event_bus.main.name +} diff --git a/infrastructure/terraform/components/dl/lambda_event_source_mapping_pdm_poll_lambda.tf b/infrastructure/terraform/components/dl/lambda_event_source_mapping_pdm_poll_lambda.tf new file mode 100644 index 00000000..acd13036 --- /dev/null +++ b/infrastructure/terraform/components/dl/lambda_event_source_mapping_pdm_poll_lambda.tf @@ -0,0 +1,10 @@ +resource "aws_lambda_event_source_mapping" "pdm_poll_lambda" { + event_source_arn = module.sqs_pdm_poll.sqs_queue_arn + function_name = module.pdm_poll.function_name + batch_size = var.queue_batch_size + maximum_batching_window_in_seconds = var.queue_batch_window_seconds + + function_response_types = [ + "ReportBatchItemFailures" + ] +} diff --git a/infrastructure/terraform/components/dl/module_lambda_pdm_mock.tf b/infrastructure/terraform/components/dl/module_lambda_pdm_mock.tf index 0734c000..b4ce6313 100644 --- a/infrastructure/terraform/components/dl/module_lambda_pdm_mock.tf +++ b/infrastructure/terraform/components/dl/module_lambda_pdm_mock.tf @@ -34,12 +34,6 @@ module "pdm_mock" { log_destination_arn = local.log_destination_arn log_subscription_role_arn = local.acct.log_subscription_role_arn - - lambda_env_vars = { - MOCK_ACCESS_TOKEN = var.pdm_mock_access_token - ACCESS_TOKEN_SSM_PATH = local.apim_access_token_ssm_parameter_name - USE_NON_MOCK_TOKEN = var.pdm_use_non_mock_token - } } data "aws_iam_policy_document" "pdm_mock" { diff --git a/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf new file mode 100644 index 00000000..5ee748b1 --- /dev/null +++ b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf @@ -0,0 +1,102 @@ +module "pdm_poll" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip" + + function_name = "pdm-poll" + description = "A function for polling PDM document status" + + aws_account_id = var.aws_account_id + component = local.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 = module.kms.key_arn + + iam_policy_document = { + body = data.aws_iam_policy_document.pdm_poll_lambda.json + } + + function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] + function_code_base_path = local.aws_lambda_functions_dir_path + function_code_dir = "pdm-poll-lambda/dist" + function_include_common = true + handler_function_name = "handler" + runtime = "nodejs22.x" + memory = 128 + timeout = 60 + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + + log_destination_arn = local.log_destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + lambda_env_vars = { + "APIM_BASE_URL" = local.deploy_pdm_mock ? aws_api_gateway_stage.pdm_mock[0].invoke_url : var.apim_base_url + "APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME" = local.apim_access_token_ssm_parameter_name + "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn + "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "POLL_MAX_RETRIES" = 10 + } +} + +data "aws_iam_policy_document" "pdm_poll_lambda" { + statement { + sid = "AllowSSMParam" + effect = "Allow" + + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ] + + resources = [ + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.component}/${var.environment}/apim/*" + ] + } + statement { + sid = "PutEvents" + effect = "Allow" + + actions = [ + "events:PutEvents", + ] + + resources = [ + aws_cloudwatch_event_bus.main.arn, + ] + } + + statement { + sid = "SQSPermissionsDLQs" + effect = "Allow" + + actions = [ + "sqs:SendMessage", + "sqs:SendMessageBatch", + ] + + resources = [ + module.sqs_event_publisher_errors.sqs_queue_arn, + ] + } + statement { + sid = "SQSPermissionsPollPdmQueue" + effect = "Allow" + + actions = [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl", + ] + + resources = [ + module.sqs_pdm_poll.sqs_queue_arn, + ] + } +} diff --git a/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf b/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf new file mode 100644 index 00000000..f7cb3842 --- /dev/null +++ b/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf @@ -0,0 +1,35 @@ +module "sqs_pdm_poll" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" + + aws_account_id = var.aws_account_id + component = local.component + environment = var.environment + project = var.project + region = var.region + name = "pdm-poll" + sqs_kms_key_arn = module.kms.key_arn + visibility_timeout_seconds = 60 + delay_seconds = 5 + create_dlq = true + sqs_policy_overload = data.aws_iam_policy_document.sqs_pdm_poll.json +} + +data "aws_iam_policy_document" "sqs_pdm_poll" { + 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}-pdm-poll-queue" + ] + } +} diff --git a/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf b/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf index a9b1b33a..cb45762a 100644 --- a/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf +++ b/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf @@ -32,7 +32,7 @@ data "aws_iam_policy_document" "sqs_pdm_uploader" { ] resources = [ - "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${local.component}-pdm-uploader-queue" + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-pdm-uploader-queue" ] } } diff --git a/infrastructure/terraform/components/dl/module_sqs_ttl.tf b/infrastructure/terraform/components/dl/module_sqs_ttl.tf index 055c0ff9..38638a2b 100644 --- a/infrastructure/terraform/components/dl/module_sqs_ttl.tf +++ b/infrastructure/terraform/components/dl/module_sqs_ttl.tf @@ -32,7 +32,7 @@ data "aws_iam_policy_document" "sqs_ttl" { ] resources = [ - "arn:aws:sqs:${var.region}:${var.aws_account_id}:${var.project}-${var.environment}-${local.component}-ttl-queue" + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-ttl-queue" ] } } diff --git a/infrastructure/terraform/components/dl/variables.tf b/infrastructure/terraform/components/dl/variables.tf index d3f63e02..028ec53e 100644 --- a/infrastructure/terraform/components/dl/variables.tf +++ b/infrastructure/terraform/components/dl/variables.tf @@ -101,7 +101,7 @@ variable "queue_batch_size" { variable "queue_batch_window_seconds" { type = number description = "maximum time in seconds between processing events" - default = 10 + default = 1 } variable "enable_dynamodb_delete_protection" { diff --git a/lambdas/pdm-mock-lambda/README.md b/lambdas/pdm-mock-lambda/README.md index da9d8c5c..c0ec5b0c 100644 --- a/lambdas/pdm-mock-lambda/README.md +++ b/lambdas/pdm-mock-lambda/README.md @@ -27,8 +27,8 @@ curl -X POST https:///patient-data-manager/FHIR/R4/DocumentRefe **Headers:** -- `Authorization: Bearer ` - Required authentication token (default: `mock-pdm-token`) -- `Content-Type: application/fhir+json` - Required content type +- `Authorization: Bearer ` - Authentication token is not validated and can be any string value. +- `Content-Type: application/fhir+json` - Required content type. - `X-Request-ID: ` - This uuid will be used as the DocumentReference `id` in the response. **Response (201 Created):** @@ -76,8 +76,8 @@ curl https:///patient-data-manager/FHIR/R4/DocumentReference/te **Headers:** -- `Authorization: Bearer ` - Required authentication token (default: `mock-pdm-token`) -- `Content-Type: application/fhir+json` - Required content type +- `Authorization: Bearer ` - Authentication token is not validated and can be any string value. +- `Content-Type: application/fhir+json` - Required content type. - `X-Request-ID: ` - Used for request tracking and correlation. This isn't part of the ID or response that gets returned. **Response (200 OK):** @@ -136,17 +136,18 @@ Both GET and POST endpoints require the `X-Request-ID` header. If it's missing, The mock API supports triggering specific error responses for testing in both endpoints. Use these special resource IDs: -| Resource ID | Status Code | Error Code | Description | -| ------------------------ | ----------- | ------------------- | ------------------------------- | -| `error-400-invalid` | 400 | INVALID_VALUE | Invalid resource value | -| `error-401-unauthorized` | 401 | UNAUTHORISED | Unauthorized access | -| `error-403-forbidden` | 403 | FORBIDDEN | Access forbidden | -| `error-404-notfound` | 404 | RESOURCE_NOT_FOUND | Resource not found | -| `error-409-conflict` | 409 | CONFLICT | Resource already exists | -| `error-429-ratelimit` | 429 | TOO_MANY_REQUESTS | Rate limit exceeded | -| `error-500-internal` | 500 | INTERNAL_ERROR | Internal server error | -| `error-503-unavailable` | 503 | SERVICE_UNAVAILABLE | Service temporarily unavailable | -| `empty-response` | 200 | - | Empty success response | +| Resource ID | Status Code | Error Code | Description | +| ------------------------ | ----------- | ------------------- | ---------------------------------------- | +| `error-400-invalid` | 400 | INVALID_VALUE | Invalid resource value | +| `error-401-unauthorized` | 401 | UNAUTHORISED | Unauthorized access | +| `error-403-forbidden` | 403 | FORBIDDEN | Access forbidden | +| `error-404-notfound` | 404 | RESOURCE_NOT_FOUND | Resource not found | +| `error-409-conflict` | 409 | CONFLICT | Resource already exists | +| `error-429-ratelimit` | 429 | TOO_MANY_REQUESTS | Rate limit exceeded | +| `error-500-internal` | 500 | INTERNAL_ERROR | Internal server error | +| `error-503-unavailable` | 503 | SERVICE_UNAVAILABLE | Service temporarily unavailable | +| `empty-response` | 200 | - | Empty success response | +| `unavailable-response` | 200 | - | Success response with no attachment.data | **Example - Trigger 404 Error:** @@ -177,9 +178,6 @@ curl https:///resource/error-404-notfound \ The lambda is configured via environment variables: -| Variable | Description | Default | -| ----------------------- | ---------------------------------------- | -------------------------- | -| `MOCK_ACCESS_TOKEN` | Token to use in local/dev environments | `mock-pdm-token` | -| `ACCESS_TOKEN_SSM_PATH` | SSM parameter path for the access token | `/dl/main/apim/access_token`| -| `USE_NON_MOCK_TOKEN` | Use SSM token instead of mock token | `false` | -| `LOG_LEVEL` | Logging level (DEBUG, INFO, WARN, ERROR) | `INFO` | +| Variable | Description | Default | +| ----------------------- | ---------------------------------------- | ------------------------ | +| `LOG_LEVEL` | Logging level (DEBUG, INFO, WARN, ERROR) | `INFO` | diff --git a/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts b/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts index 5ab8dc55..86db5e8c 100644 --- a/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts +++ b/lambdas/pdm-mock-lambda/src/__tests__/authenticator.test.ts @@ -10,41 +10,23 @@ const mockLogger: Logger = { } as any; describe('Authenticator', () => { - let mockGetAccessToken: jest.Mock; - beforeEach(() => { jest.clearAllMocks(); - mockGetAccessToken = jest.fn(); }); describe('with mock token', () => { it('should authenticate successfully with valid Bearer token', async () => { - const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: false, - getAccessToken: mockGetAccessToken, - }, - mockLogger, - ); + const authenticator = createAuthenticator(mockLogger); const result = await authenticator({ headers: { Authorization: 'Bearer test-token' }, }); expect(result.isValid).toBe(true); - expect(mockGetAccessToken).not.toHaveBeenCalled(); }); it('should reject request with missing Authorization header', async () => { - const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: false, - getAccessToken: mockGetAccessToken, - }, - mockLogger, - ); + const authenticator = createAuthenticator(mockLogger); const result = await authenticator({ headers: {} }); @@ -63,14 +45,7 @@ describe('Authenticator', () => { }); it('should reject request with invalid token type', async () => { - const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: false, - getAccessToken: mockGetAccessToken, - }, - mockLogger, - ); + const authenticator = createAuthenticator(mockLogger); const result = await authenticator({ headers: { Authorization: 'Basic test-token' }, @@ -86,39 +61,8 @@ describe('Authenticator', () => { ); }); - it('should reject request with invalid token value', async () => { - const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: false, - getAccessToken: mockGetAccessToken, - }, - mockLogger, - ); - - const result = await authenticator({ - headers: { Authorization: 'Bearer wrong-token' }, - }); - - expect(result.isValid).toBe(false); - expect(result).toHaveProperty('error'); - expect((result as { isValid: false; error: any }).error.statusCode).toBe( - 401, - ); - expect((result as { isValid: false; error: any }).error.body).toContain( - 'Invalid Access Token', - ); - }); - it('should handle lowercase authorization header', async () => { - const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: false, - getAccessToken: mockGetAccessToken, - }, - mockLogger, - ); + const authenticator = createAuthenticator(mockLogger); const result = await authenticator({ headers: { authorization: 'Bearer test-token' }, @@ -127,68 +71,4 @@ describe('Authenticator', () => { expect(result.isValid).toBe(true); }); }); - - describe('with non-mock token', () => { - it('should authenticate successfully with SSM token', async () => { - mockGetAccessToken.mockResolvedValue('ssm-token'); - - const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: true, - getAccessToken: mockGetAccessToken, - }, - mockLogger, - ); - - const result = await authenticator({ - headers: { Authorization: 'Bearer ssm-token' }, - }); - - expect(result.isValid).toBe(true); - expect(mockGetAccessToken).toHaveBeenCalledTimes(1); - }); - - it('should reject request with mock token when non-mock token is required', async () => { - mockGetAccessToken.mockResolvedValue('ssm-token'); - - const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: true, - getAccessToken: mockGetAccessToken, - }, - mockLogger, - ); - - const result = await authenticator({ - headers: { Authorization: 'Bearer test-token' }, - }); - - expect(result.isValid).toBe(false); - expect(result).toHaveProperty('error'); - expect((result as { isValid: false; error: any }).error.statusCode).toBe( - 401, - ); - }); - - it('should handle SSM token retrieval errors gracefully', async () => { - mockGetAccessToken.mockRejectedValue(new Error('SSM error')); - - const authenticator = createAuthenticator( - { - mockAccessToken: 'test-token', - useNonMockToken: true, - getAccessToken: mockGetAccessToken, - }, - mockLogger, - ); - - await expect( - authenticator({ - headers: { Authorization: 'Bearer test-token' }, - }), - ).rejects.toThrow('SSM error'); - }); - }); }); diff --git a/lambdas/pdm-mock-lambda/src/__tests__/config.test.ts b/lambdas/pdm-mock-lambda/src/__tests__/config.test.ts deleted file mode 100644 index 17d57516..00000000 --- a/lambdas/pdm-mock-lambda/src/__tests__/config.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { loadConfig } from 'config'; - -describe('Config', () => { - const originalEnv = process.env; - - beforeEach(() => { - jest.resetModules(); - process.env = { ...originalEnv }; - }); - - afterAll(() => { - process.env = originalEnv; - }); - - describe('loadConfig', () => { - it('should load config with default values', () => { - delete process.env.MOCK_ACCESS_TOKEN; - delete process.env.ACCESS_TOKEN_SSM_PATH; - delete process.env.USE_NON_MOCK_TOKEN; - delete process.env.LOG_LEVEL; - - const config = loadConfig(); - - expect(config.mockAccessToken).toBe('mock-pdm-token'); - expect(config.accessTokenSsmPath).toBe('/dl/main/apim/access_token'); - expect(config.useNonMockToken).toBe(false); - expect(config.logLevel).toBe('INFO'); - }); - - it('should load config from environment variables', () => { - process.env.MOCK_ACCESS_TOKEN = 'custom-token'; - process.env.ACCESS_TOKEN_SSM_PATH = '/custom/path'; - process.env.USE_NON_MOCK_TOKEN = 'true'; - process.env.LOG_LEVEL = 'DEBUG'; - - const config = loadConfig(); - - expect(config.mockAccessToken).toBe('custom-token'); - expect(config.accessTokenSsmPath).toBe('/custom/path'); - expect(config.useNonMockToken).toBe(true); - expect(config.logLevel).toBe('DEBUG'); - }); - - it('should parse boolean environment variables correctly', () => { - process.env.USE_NON_MOCK_TOKEN = 'TRUE'; - let config = loadConfig(); - expect(config.useNonMockToken).toBe(true); - - process.env.USE_NON_MOCK_TOKEN = 'true'; - config = loadConfig(); - expect(config.useNonMockToken).toBe(true); - - process.env.USE_NON_MOCK_TOKEN = 'false'; - config = loadConfig(); - expect(config.useNonMockToken).toBe(false); - - process.env.USE_NON_MOCK_TOKEN = 'FALSE'; - config = loadConfig(); - expect(config.useNonMockToken).toBe(false); - }); - - it('should not throw when all required env vars have default values', () => { - const config = loadConfig(); - expect(config).toBeDefined(); - }); - }); -}); diff --git a/lambdas/pdm-mock-lambda/src/__tests__/container.test.ts b/lambdas/pdm-mock-lambda/src/__tests__/container.test.ts index 963a7adf..4cfda0ac 100644 --- a/lambdas/pdm-mock-lambda/src/__tests__/container.test.ts +++ b/lambdas/pdm-mock-lambda/src/__tests__/container.test.ts @@ -1,5 +1,4 @@ import { createContainer } from 'container'; -import { parameterStore } from 'utils'; jest.mock('utils', () => { const actual = jest.requireActual('utils'); @@ -25,9 +24,6 @@ describe('Container', () => { beforeEach(() => { jest.clearAllMocks(); process.env = { ...originalEnv }; - process.env.MOCK_ACCESS_TOKEN = 'test-token'; - process.env.ACCESS_TOKEN_SSM_PATH = '/test/path'; - process.env.USE_NON_MOCK_TOKEN = 'false'; process.env.LOG_LEVEL = 'INFO'; container = createContainer(); @@ -88,76 +84,4 @@ describe('Container', () => { expect(result).toBeDefined(); expect(result.isValid).toBeDefined(); }); - - it('should handle USE_NON_MOCK_TOKEN configuration', () => { - process.env.USE_NON_MOCK_TOKEN = 'true'; - const containerWithSSM = createContainer(); - - expect(containerWithSSM).toBeDefined(); - expect(containerWithSSM.authenticator).toBeDefined(); - expect(typeof containerWithSSM.authenticator).toBe('function'); - }); - - it('should wire getAccessToken to authenticator when using SSM token', async () => { - const mockTokenValue = JSON.stringify({ - access_token: 'ssm-stored-token', - expires_at: 1_765_187_843, - token_type: 'Bearer', - }); - - (parameterStore.getParameter as jest.Mock).mockResolvedValue({ - Value: mockTokenValue, - }); - - process.env.USE_NON_MOCK_TOKEN = 'true'; - process.env.ACCESS_TOKEN_SSM_PATH = '/test/token/path'; - process.env.MOCK_ACCESS_TOKEN = 'unused-mock-token'; - - const testContainer = createContainer(); - - const result = await testContainer.authenticator({ - headers: { Authorization: 'Bearer ssm-stored-token' }, - }); - - expect(result.isValid).toBe(true); - expect(parameterStore.getParameter).toHaveBeenCalledWith( - '/test/token/path', - ); - }); - - it('should handle invalid JSON format in SSM parameter', async () => { - (parameterStore.getParameter as jest.Mock).mockResolvedValue({ - Value: 'invalid-json', - }); - - process.env.USE_NON_MOCK_TOKEN = 'true'; - process.env.ACCESS_TOKEN_SSM_PATH = '/test/token/path'; - - const testContainer = createContainer(); - - await expect( - testContainer.authenticator({ - headers: { Authorization: 'Bearer any-token' }, - }), - ).rejects.toThrow('Invalid access token format in SSM parameter'); - }); - - it('should handle missing SSM parameter', async () => { - (parameterStore.getParameter as jest.Mock).mockResolvedValue({ - Value: undefined, - }); - - process.env.USE_NON_MOCK_TOKEN = 'true'; - process.env.ACCESS_TOKEN_SSM_PATH = '/test/token/path'; - - const testContainer = createContainer(); - - await expect( - testContainer.authenticator({ - headers: { Authorization: 'Bearer any-token' }, - }), - ).rejects.toThrow( - 'Access token parameter "/test/token/path" not found in SSM', - ); - }); }); diff --git a/lambdas/pdm-mock-lambda/src/__tests__/handlers.test.ts b/lambdas/pdm-mock-lambda/src/__tests__/handlers.test.ts index e4cfc6da..d7035724 100644 --- a/lambdas/pdm-mock-lambda/src/__tests__/handlers.test.ts +++ b/lambdas/pdm-mock-lambda/src/__tests__/handlers.test.ts @@ -74,6 +74,10 @@ describe('GET Resource Handler', () => { expect(body.meta).toBeDefined(); expect(body.meta.versionId).toBe('1'); expect(body.meta.lastUpdated).toBeDefined(); + expect(body.author[0].identifier.system).toBe( + 'https://fhir.nhs.uk/Id/ods-organization-code', + ); + expect(body.author[0].identifier.value).toBe('Y05868'); expect(body.subject.identifier.system).toBe( 'https://fhir.nhs.uk/Id/nhs-number', ); @@ -84,6 +88,40 @@ describe('GET Resource Handler', () => { expect(body.content[0].attachment.title).toBe('Dummy PDF'); }); + it('should return response with no attachment.data for unavailable-response', async () => { + const handler = createGetResourceHandler(mockLogger); + const event = createMockEvent({ + pathParameters: { id: 'unavailable-response' }, + headers: { + 'X-Request-ID': 'get-test-1234-5678-9abc-def012345678', + }, + }); + + const response = await handler(event); + + expect(response.statusCode).toBe(200); + expect(response.headers?.['Content-Type']).toBe('application/fhir+json'); + + const body = JSON.parse(response.body); + expect(body.resourceType).toBe('DocumentReference'); + expect(body.id).toBe('unavailable-response'); + expect(body.status).toBe('current'); + expect(body.meta).toBeDefined(); + expect(body.meta.versionId).toBe('1'); + expect(body.meta.lastUpdated).toBeDefined(); + expect(body.author[0].identifier.system).toBe( + 'https://fhir.nhs.uk/Id/ods-organization-code', + ); + expect(body.author[0].identifier.value).toBe('Y05868'); + expect(body.subject.identifier.system).toBe( + 'https://fhir.nhs.uk/Id/nhs-number', + ); + expect(body.subject.identifier.value).toBe('9912003071'); + expect(body.content[0].attachment.contentType).toBe('application/pdf'); + expect(body.content[0].attachment.data).not.toBeDefined(); + expect(body.content[0].attachment.title).toBe('Dummy PDF'); + }); + it('should return 400 error when resource ID is missing', async () => { const handler = createGetResourceHandler(mockLogger); const event = createMockEvent({ diff --git a/lambdas/pdm-mock-lambda/src/authenticator.ts b/lambdas/pdm-mock-lambda/src/authenticator.ts index b6987c91..000de2db 100644 --- a/lambdas/pdm-mock-lambda/src/authenticator.ts +++ b/lambdas/pdm-mock-lambda/src/authenticator.ts @@ -1,17 +1,11 @@ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import type { Logger } from 'utils'; -export interface AuthConfig { - mockAccessToken: string; - useNonMockToken: boolean; - getAccessToken: () => Promise; -} - export type AuthResult = | { isValid: true } | { isValid: false; error: APIGatewayProxyResult }; -export const createAuthenticator = (authConfig: AuthConfig, logger: Logger) => { +export const createAuthenticator = (logger: Logger) => { return async ( event: Pick, ): Promise => { @@ -41,7 +35,7 @@ export const createAuthenticator = (authConfig: AuthConfig, logger: Logger) => { }; } - const [tokenType, token] = authHeader.split(' '); + const [tokenType] = authHeader.split(' '); if (tokenType !== 'Bearer') { logger.warn(tokenType, 'Invalid token type'); @@ -66,34 +60,6 @@ export const createAuthenticator = (authConfig: AuthConfig, logger: Logger) => { }; } - const validToken = authConfig.useNonMockToken - ? await authConfig.getAccessToken() - : authConfig.mockAccessToken; - - // eslint-disable-next-line security/detect-possible-timing-attacks - if (token !== validToken) { - logger.warn('Token validation failed'); - return { - isValid: false, - error: { - statusCode: 401, - body: JSON.stringify({ - resourceType: 'OperationOutcome', - issue: [ - { - severity: 'error', - code: 'forbidden', - details: { - coding: [{ code: 'ACCESS_DENIED' }], - }, - diagnostics: 'Invalid Access Token', - }, - ], - }), - }, - }; - } - logger.debug('Authentication successful'); return { isValid: true }; }; diff --git a/lambdas/pdm-mock-lambda/src/config.ts b/lambdas/pdm-mock-lambda/src/config.ts deleted file mode 100644 index 977e6762..00000000 --- a/lambdas/pdm-mock-lambda/src/config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defaultConfigReader } from 'utils'; - -export interface Config { - mockAccessToken: string; - accessTokenSsmPath: string; - useNonMockToken: boolean; - logLevel: string; -} - -export const loadConfig = (): Config => { - const mockAccessToken = - defaultConfigReader.tryGetValue('MOCK_ACCESS_TOKEN') || 'mock-pdm-token'; - const accessTokenSsmPath = - defaultConfigReader.tryGetValue('ACCESS_TOKEN_SSM_PATH') || - '/dl/main/apim/access_token'; - const useNonMockToken = - defaultConfigReader.tryGetBoolean('USE_NON_MOCK_TOKEN') || false; - const logLevel = defaultConfigReader.tryGetValue('LOG_LEVEL') || 'INFO'; - - return { - mockAccessToken, - accessTokenSsmPath, - useNonMockToken, - logLevel, - }; -}; diff --git a/lambdas/pdm-mock-lambda/src/container.ts b/lambdas/pdm-mock-lambda/src/container.ts index fea6e382..2824c465 100644 --- a/lambdas/pdm-mock-lambda/src/container.ts +++ b/lambdas/pdm-mock-lambda/src/container.ts @@ -1,5 +1,4 @@ -import { logger, parameterStore } from 'utils'; -import { loadConfig } from 'config'; +import { logger } from 'utils'; import { createAuthenticator } from 'authenticator'; import { createCreateResourceHandler, @@ -14,38 +13,7 @@ export interface Container { } export const createContainer = (): Container => { - const config = loadConfig(); - - const getAccessToken = async () => { - const parameter = await parameterStore.getParameter( - config.accessTokenSsmPath, - ); - if (!parameter?.Value) { - throw new Error( - `Access token parameter "${config.accessTokenSsmPath}" not found in SSM`, - ); - } - - try { - const parsed = JSON.parse(parameter.Value); - return parsed.access_token; - } catch (error) { - logger.error('Failed to parse access token from SSM', { - error, - value: parameter.Value, - }); - throw new Error('Invalid access token format in SSM parameter'); - } - }; - - const authenticator = createAuthenticator( - { - mockAccessToken: config.mockAccessToken, - useNonMockToken: config.useNonMockToken, - getAccessToken, - }, - logger, - ); + const authenticator = createAuthenticator(logger); const getResourceHandler = createGetResourceHandler(logger); const createResourceHandler = createCreateResourceHandler(logger); diff --git a/lambdas/pdm-mock-lambda/src/handlers.ts b/lambdas/pdm-mock-lambda/src/handlers.ts index 800d9a6d..a7b5ad1d 100644 --- a/lambdas/pdm-mock-lambda/src/handlers.ts +++ b/lambdas/pdm-mock-lambda/src/handlers.ts @@ -158,6 +158,14 @@ const generateMockResource = ( lastUpdated: new Date().toISOString(), }, status: 'current', + author: [ + { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'Y05868', + }, + }, + ], subject: { identifier: { system: 'https://fhir.nhs.uk/Id/nhs-number', @@ -236,7 +244,8 @@ export const createGetResourceHandler = (logger: Logger) => { return createEmptySuccessResponse(); } - const resource = generateMockResource(resourceId, true); + const includeData = resourceId !== 'unavailable-response'; + const resource = generateMockResource(resourceId, includeData); logger.info('Returning mock resource', { resourceId, requestId }); return createResourceResponse(resource); }; diff --git a/lambdas/pdm-poll-lambda/jest.config.ts b/lambdas/pdm-poll-lambda/jest.config.ts new file mode 100644 index 00000000..c02601ae --- /dev/null +++ b/lambdas/pdm-poll-lambda/jest.config.ts @@ -0,0 +1,5 @@ +import { baseJestConfig } from '../../jest.config.base'; + +const config = baseJestConfig; + +export default config; diff --git a/lambdas/pdm-poll-lambda/package.json b/lambdas/pdm-poll-lambda/package.json new file mode 100644 index 00000000..8a1d0894 --- /dev/null +++ b/lambdas/pdm-poll-lambda/package.json @@ -0,0 +1,25 @@ +{ + "dependencies": { + "aws-lambda": "^1.0.7", + "digital-letters-events": "^0.0.1", + "utils": "^0.0.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/aws-lambda": "^8.10.155", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "jest-mock-extended": "^3.0.7", + "typescript": "^5.9.3" + }, + "name": "nhs-notify-digital-letters-pdm-poll-lambda", + "private": true, + "scripts": { + "lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --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/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts new file mode 100644 index 00000000..74c1ac35 --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/__tests__/apis/sqs-handler.test.ts @@ -0,0 +1,343 @@ +import { mock } from 'jest-mock-extended'; +import { randomUUID } from 'node:crypto'; +import { createHandler } from 'apis/sqs-handler'; +import { EventPublisher, Logger } from 'utils'; +import { Pdm } from 'app/pdm'; +import { + pdmResourceSubmittedEvent, + pdmResourceUnavailableEvent, + recordEvent, +} from '__tests__/test-data'; + +const logger = mock(); +const eventPublisher = mock(); +const pdm = mock(); + +jest.mock('node:crypto', () => ({ + randomUUID: jest.fn(), +})); + +const mockRandomUUID = randomUUID as jest.MockedFunction; +const mockDate = jest.spyOn(Date.prototype, 'toISOString'); +mockRandomUUID.mockReturnValue('550e8400-e29b-41d4-a716-446655440001'); +mockDate.mockReturnValue('2023-06-20T12:00:00.250Z'); + +const handler = createHandler({ + eventPublisher, + logger, + pdm, + pollMaxRetries: 10, +}); + +describe('SQS Handler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('pdm.resource.submitted', () => { + it('should send pdm.resource.available event when the document is ready', async () => { + pdm.poll.mockResolvedValueOnce({ + pdmAvailability: 'available', + nhsNumber: '9999999999', + odsCode: 'AB1234', + }); + + const response = await handler(recordEvent([pdmResourceSubmittedEvent])); + + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + { + ...pdmResourceSubmittedEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + data: { + messageReference: pdmResourceSubmittedEvent.data.messageReference, + senderId: pdmResourceSubmittedEvent.data.senderId, + resourceId: pdmResourceSubmittedEvent.data.resourceId, + nhsNumber: '9999999999', + odsCode: 'AB1234', + }, + }, + ], + expect.any(Function), + ); + expect(logger.info).toHaveBeenCalledWith( + 'Received SQS Event of 1 record(s)', + ); + expect(logger.info).toHaveBeenCalledWith( + '1 of 1 records processed successfully', + ); + expect(response).toEqual({ batchItemFailures: [] }); + }); + + it('should send pdm.resource.unavailable event when the document is not ready', async () => { + pdm.poll.mockResolvedValueOnce({ + pdmAvailability: 'unavailable', + nhsNumber: '9999999999', + odsCode: 'AB1234', + }); + + const response = await handler(recordEvent([pdmResourceSubmittedEvent])); + + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + { + ...pdmResourceSubmittedEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + data: { + messageReference: pdmResourceSubmittedEvent.data.messageReference, + senderId: pdmResourceSubmittedEvent.data.senderId, + resourceId: pdmResourceSubmittedEvent.data.resourceId, + retryCount: 0, + }, + }, + ], + expect.any(Function), + ); + expect(logger.info).toHaveBeenCalledWith( + 'Received SQS Event of 1 record(s)', + ); + expect(logger.info).toHaveBeenCalledWith( + '1 of 1 records processed successfully', + ); + expect(response).toEqual({ batchItemFailures: [] }); + }); + }); + + describe('pdm.resource.unavailable', () => { + it('should send pdm.resource.available event when the document is ready', async () => { + pdm.poll.mockResolvedValueOnce({ + pdmAvailability: 'available', + nhsNumber: '9999999999', + odsCode: 'AB1234', + }); + + const response = await handler( + recordEvent([pdmResourceUnavailableEvent]), + ); + + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + { + ...pdmResourceUnavailableEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + data: { + messageReference: + pdmResourceUnavailableEvent.data.messageReference, + senderId: pdmResourceUnavailableEvent.data.senderId, + resourceId: pdmResourceUnavailableEvent.data.resourceId, + nhsNumber: '9999999999', + odsCode: 'AB1234', + }, + }, + ], + expect.any(Function), + ); + expect(logger.info).toHaveBeenCalledWith( + 'Received SQS Event of 1 record(s)', + ); + expect(logger.info).toHaveBeenCalledWith( + '1 of 1 records processed successfully', + ); + expect(response).toEqual({ batchItemFailures: [] }); + }); + + it('should send pdm.resource.unavailable event when the document is not ready', async () => { + pdm.poll.mockResolvedValueOnce({ + pdmAvailability: 'unavailable', + nhsNumber: '9999999999', + odsCode: 'AB1234', + }); + + const response = await handler( + recordEvent([pdmResourceUnavailableEvent]), + ); + + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + { + ...pdmResourceUnavailableEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + data: { + messageReference: + pdmResourceUnavailableEvent.data.messageReference, + senderId: pdmResourceUnavailableEvent.data.senderId, + resourceId: pdmResourceUnavailableEvent.data.resourceId, + retryCount: 2, + }, + }, + ], + expect.any(Function), + ); + expect(logger.info).toHaveBeenCalledWith( + 'Received SQS Event of 1 record(s)', + ); + expect(logger.info).toHaveBeenCalledWith( + '1 of 1 records processed successfully', + ); + expect(response).toEqual({ batchItemFailures: [] }); + }); + + it('should send pdm.resource.retries.exceeded event when the document is not ready after 10 retries', async () => { + pdm.poll.mockResolvedValueOnce({ + pdmAvailability: 'unavailable', + nhsNumber: '9999999999', + odsCode: 'AB1234', + }); + + const testEvent = { + ...pdmResourceUnavailableEvent, + data: { + ...pdmResourceUnavailableEvent.data, + retryCount: 9, + }, + }; + + const response = await handler(recordEvent([testEvent])); + + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + { + ...pdmResourceUnavailableEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-retries-exceeded-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1', + data: { + messageReference: + pdmResourceUnavailableEvent.data.messageReference, + senderId: pdmResourceUnavailableEvent.data.senderId, + resourceId: pdmResourceUnavailableEvent.data.resourceId, + retryCount: 10, + }, + }, + ], + expect.any(Function), + ); + expect(logger.info).toHaveBeenCalledWith( + 'Received SQS Event of 1 record(s)', + ); + expect(logger.info).toHaveBeenCalledWith( + '1 of 1 records processed successfully', + ); + expect(response).toEqual({ batchItemFailures: [] }); + }); + }); + + describe('errors', () => { + it('should return failed SQS records to the queue if an error occurs while calling PDM', async () => { + pdm.poll.mockRejectedValueOnce(new Error('PDM error')); + const event = recordEvent([pdmResourceSubmittedEvent]); + + const result = await handler(event); + + expect(logger.warn).toHaveBeenCalledWith({ + err: 'PDM error', + description: 'Failed processing message', + }); + + expect(logger.info).toHaveBeenCalledWith( + '0 of 1 records processed successfully', + ); + + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: '1' }], + }); + }); + + it('should return failed SQS records to the queue if an error occurs while processing them', async () => { + const event = recordEvent([pdmResourceSubmittedEvent]); + event.Records[0].body = 'not-json'; + + const result = await handler(event); + + expect(logger.warn).toHaveBeenCalledWith({ + err: new SyntaxError( + `Unexpected token 'o', "not-json" is not valid JSON`, + ), + description: 'Error parsing SQS record', + }); + + expect(logger.info).toHaveBeenCalledWith( + '0 of 1 records processed successfully', + ); + + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: '1' }], + }); + }); + + it('should return failed items to the queue if an invalid pdm.resource.submitted event is received', async () => { + const invalidSubmittedEvent = { + ...pdmResourceSubmittedEvent, + source: 'invalid pdm.resource.submitted source', + }; + const event = recordEvent([invalidSubmittedEvent]); + + const result = await handler(event); + + expect(logger.warn).toHaveBeenCalledWith({ + err: expect.arrayContaining([ + expect.objectContaining({ + instancePath: '/source', + }), + ]), + description: 'Error parsing queue entry', + }); + + expect(logger.info).toHaveBeenCalledWith( + '0 of 1 records processed successfully', + ); + + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: '1' }], + }); + }); + + it('should return failed items to the queue if an invalid pdm.resource.unavailable event is received', async () => { + const invalidSubmittedEvent = { + ...pdmResourceUnavailableEvent, + source: 'invalid pdm.resource.unavailable source', + }; + const event = recordEvent([invalidSubmittedEvent]); + + const result = await handler(event); + + expect(logger.warn).toHaveBeenCalledWith({ + err: expect.arrayContaining([ + expect.objectContaining({ + instancePath: '/source', + }), + ]), + description: 'Error parsing queue entry', + }); + + expect(logger.info).toHaveBeenCalledWith( + '0 of 1 records processed successfully', + ); + + expect(result).toEqual({ + batchItemFailures: [{ itemIdentifier: '1' }], + }); + }); + }); +}); diff --git a/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts new file mode 100644 index 00000000..b4f96109 --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/__tests__/app/pdm.test.ts @@ -0,0 +1,159 @@ +import { mock } from 'jest-mock-extended'; +import { IPdmClient, Logger } from 'utils'; +import { Pdm, PdmDependencies } from 'app/pdm'; +import { pdmResourceSubmittedEvent } from '__tests__/test-data'; + +const logger = mock(); +const pdmClient = mock(); +const validConfig = (): PdmDependencies => ({ + pdmClient, + logger, +}); + +const availableResponse = { + resourceType: 'DocumentReference', + id: '4c5af7c3-ca21-31b8-924b-fa526db5379b', + meta: { + versionId: '1', + lastUpdated: '2025-12-10T09:00:47.068021Z', + }, + status: 'current', + author: [ + { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'Y05868', + }, + }, + ], + subject: { + identifier: { + system: 'https://fhir.nhs.uk/Id/nhs-number', + value: '9912003071', + }, + }, + content: [ + { + attachment: { + contentType: 'application/pdf', + data: 'base64-encoded-pdf', + title: 'Dummy PDF', + }, + }, + ], +}; + +describe('Pdm', () => { + describe('constructor', () => { + it('is created when required deps are provided', () => { + const cfg = validConfig(); + expect(() => new Pdm(cfg)).not.toThrow(); + }); + + it('throws if pdmClient is not provided', () => { + const cfg = { + logger, + } as unknown as PdmDependencies; + + expect(() => new Pdm(cfg)).toThrow('pdmClient has not been specified'); + }); + + it('throws if logger is not provided', () => { + const cfg = { + pdmClient, + } as unknown as PdmDependencies; + + expect(() => new Pdm(cfg)).toThrow('logger has not been provided'); + }); + }); + + describe('poll', () => { + it('returns available when the document is ready', async () => { + const cfg = validConfig(); + pdmClient.getDocumentReference.mockResolvedValue(availableResponse); + + const pdm = new Pdm(cfg); + + const result = await pdm.poll(pdmResourceSubmittedEvent); + + expect(result).toEqual({ + pdmAvailability: 'available', + nhsNumber: '9912003071', + odsCode: 'Y05868', + }); + }); + + it('returns unavailable when the document is not ready', async () => { + const cfg = validConfig(); + const unavailableResponse = { + ...availableResponse, + content: [ + { + attachment: { + contentType: 'application/pdf', + title: 'Dummy PDF', + }, + }, + ], + }; + pdmClient.getDocumentReference.mockResolvedValue(unavailableResponse); + + const pdm = new Pdm(cfg); + + const result = await pdm.poll(pdmResourceSubmittedEvent); + + expect(result).toEqual({ + pdmAvailability: 'unavailable', + nhsNumber: '9912003071', + odsCode: 'Y05868', + }); + }); + + it('logs and throws error when error from PDM', async () => { + const cfg = validConfig(); + const thrown = new Error('pdm failure'); + pdmClient.getDocumentReference.mockRejectedValueOnce(thrown); + + const pdm = new Pdm(cfg); + + await expect(pdm.poll(pdmResourceSubmittedEvent)).rejects.toThrow(thrown); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Error getting document resource from PDM', + err: thrown, + }), + ); + }); + + it('logs and throws error when no ODS Code is found', async () => { + const cfg = validConfig(); + const thrown = new Error('No ODS organization code found'); + const noOdsCodeResponse = { + ...availableResponse, + author: [ + { + identifier: { + system: 'https://fhir.nhs.uk/Id/some-other-code', + value: '1111', + }, + }, + ], + }; + pdmClient.getDocumentReference.mockResolvedValue(noOdsCodeResponse); + + const pdm = new Pdm(cfg); + + await expect(pdm.poll(pdmResourceSubmittedEvent)).rejects.toThrow(thrown); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Error getting document resource from PDM', + err: thrown, + }), + ); + }); + }); +}); diff --git a/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts new file mode 100644 index 00000000..8833bc85 --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts @@ -0,0 +1,28 @@ +import { createContainer } from 'container'; + +jest.mock('infra/config', () => ({ + loadConfig: jest.fn(() => ({ + apimBaseUrl: 'https://test-apim-url', + apimAccessTokenSsmParameterName: 'test-ssm-parameter-name', + eventPublisherDlqUrl: 'test-url', + eventPublisherEventBusArn: 'test-arn', + maxPollCount: 10, + })), +})); + +jest.mock('utils', () => ({ + createGetApimAccessToken: jest.fn(() => ({})), + eventBridgeClient: {}, + EventPublisher: jest.fn(() => ({})), + logger: {}, + ParameterStoreCache: jest.fn(() => ({})), + PdmClient: jest.fn(() => ({})), + sqsClient: {}, +})); + +describe('container', () => { + it('should create container', () => { + const container = createContainer(); + expect(container).toBeDefined(); + }); +}); diff --git a/lambdas/pdm-poll-lambda/src/__tests__/index.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/index.test.ts new file mode 100644 index 00000000..b5465321 --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/__tests__/index.test.ts @@ -0,0 +1,15 @@ +import { handler } from 'index'; + +jest.mock('apis/sqs-handler', () => ({ + createHandler: jest.fn(() => jest.fn()), +})); + +jest.mock('container', () => ({ + createContainer: jest.fn(() => ({})), +})); + +describe('index', () => { + it('should export handler', () => { + expect(handler).toBeDefined(); + }); +}); diff --git a/lambdas/pdm-poll-lambda/src/__tests__/infra/config.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/infra/config.test.ts new file mode 100644 index 00000000..2902c80f --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/__tests__/infra/config.test.ts @@ -0,0 +1,15 @@ +import { loadConfig } from 'infra/config'; + +jest.mock('utils', () => ({ + defaultConfigReader: { + getValue: jest.fn(), + getInt: jest.fn(), + }, +})); + +describe('config', () => { + it('should load config', () => { + const config = loadConfig(); + expect(config).toBeDefined(); + }); +}); diff --git a/lambdas/pdm-poll-lambda/src/__tests__/test-data.ts b/lambdas/pdm-poll-lambda/src/__tests__/test-data.ts new file mode 100644 index 00000000..ec47a851 --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/__tests__/test-data.ts @@ -0,0 +1,77 @@ +import { SQSEvent, SQSRecord } from 'aws-lambda'; +import { + PDMResourceSubmitted, + PDMResourceUnavailable, +} from 'digital-letters-events'; + +const baseEvent = { + id: '550e8400-e29b-41d4-a716-446655440001', + specversion: '1.0', + source: + '/nhs/england/notify/production/primary/data-plane/digitalletters/pdm', + subject: + 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', + type: 'uk.nhs.notify.digital.letters.pdm.resource.submitted.v1', + time: '2023-06-20T12:00:00Z', + recordedtime: '2023-06-20T12:00:00.250Z', + severitynumber: 2, + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + datacontenttype: 'application/json', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', + severitytext: 'INFO', + data: { + resourceId: 'a2bcbb42-ab7e-42b6-88d6-74f8d3ca4a09', + messageReference: 'ref1', + senderId: 'sender1', + }, +}; + +export const pdmResourceSubmittedEvent = { + ...baseEvent, + type: 'uk.nhs.notify.digital.letters.pdm.resource.submitted.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-submitted-data.schema.json', +} as PDMResourceSubmitted; + +export const pdmResourceUnavailableEvent = { + ...baseEvent, + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', + data: { + ...baseEvent.data, + retryCount: 1, + }, +} as PDMResourceUnavailable; + +const busEvent = { + version: '0', + id: 'ab07d406-0797-e919-ff9b-3ad9c5498114', +}; + +const sqsRecord = { + messageId: '1', + receiptHandle: 'abc', + attributes: { + ApproximateReceiveCount: '1', + SentTimestamp: '2025-07-03T14:23:30Z', + SenderId: 'sender-id', + ApproximateFirstReceiveTimestamp: '2025-07-03T14:23:30Z', + }, + messageAttributes: {}, + md5OfBody: '', + eventSource: 'aws:sqs', + eventSourceARN: '', + awsRegion: '', +} as SQSRecord; + +export const recordEvent = ( + events: (PDMResourceSubmitted | PDMResourceUnavailable)[], +): SQSEvent => ({ + Records: events.map((event, i) => ({ + ...sqsRecord, + messageId: String(i + 1), + body: JSON.stringify({ ...busEvent, detail: event }), + })), +}); diff --git a/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts new file mode 100644 index 00000000..39ae9c72 --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/apis/sqs-handler.ts @@ -0,0 +1,234 @@ +import { Pdm } from 'app/pdm'; +import type { + SQSBatchItemFailure, + SQSBatchResponse, + SQSEvent, +} from 'aws-lambda'; +import { + PDMResourceAvailable, + PDMResourceRetriesExceeded, + PDMResourceSubmitted, + PDMResourceUnavailable, +} from 'digital-letters-events'; +import pdmResourceAvailableValidator from 'digital-letters-events/PDMResourceAvailable.js'; +import pdmResourceSubmittedValidator from 'digital-letters-events/PDMResourceSubmitted.js'; +import pdmResourceUnavailableValidator from 'digital-letters-events/PDMResourceUnavailable.js'; +import pdmResourceRetriesExceededValidator from 'digital-letters-events/PDMResourceRetriesExceeded.js'; +import { randomUUID } from 'node:crypto'; +import { EventPublisher, Logger } from 'utils'; + +export interface HandlerDependencies { + eventPublisher: EventPublisher; + logger: Logger; + pdm: Pdm; + pollMaxRetries: number; +} + +type PollableEvent = PDMResourceSubmitted | PDMResourceUnavailable; + +type ValidatedRecord = { + messageId: string; + event: PollableEvent; +}; + +function validateRecord( + { body, messageId }: { body: string; messageId: string }, + logger: Logger, +): ValidatedRecord | null { + try { + const sqsEventBody = JSON.parse(body); + const sqsEventDetail = sqsEventBody.detail; + + if ( + sqsEventDetail.type === + 'uk.nhs.notify.digital.letters.pdm.resource.submitted.v1' + ) { + const isEventValid = pdmResourceSubmittedValidator(sqsEventDetail); + if (!isEventValid) { + logger.warn({ + err: pdmResourceSubmittedValidator.errors, + description: 'Error parsing queue entry', + }); + + return null; + } + + return { messageId, event: sqsEventDetail }; + } + + const isEventValid = pdmResourceUnavailableValidator(sqsEventDetail); + if (!isEventValid) { + logger.warn({ + err: pdmResourceUnavailableValidator.errors, + description: 'Error parsing queue entry', + }); + + return null; + } + + return { messageId, event: sqsEventDetail }; + } catch (error) { + logger.warn({ + err: error, + description: 'Error parsing SQS record', + }); + return null; + } +} + +function generateAvailableEvent( + event: PollableEvent, + nhsNumber: string, + odsCode: string, +): PDMResourceAvailable { + const eventTime = new Date().toISOString(); + + return { + ...event, + id: randomUUID(), + time: eventTime, + recordedtime: eventTime, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-available-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.available.v1', + data: { + messageReference: event.data.messageReference, + senderId: event.data.senderId, + resourceId: event.data.resourceId, + nhsNumber, + odsCode, + }, + }; +} + +function generateUnavailableEvent( + event: PollableEvent, + retries: number, +): PDMResourceUnavailable { + const eventTime = new Date().toISOString(); + + return { + ...event, + id: randomUUID(), + time: eventTime, + recordedtime: eventTime, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + data: { + messageReference: event.data.messageReference, + senderId: event.data.senderId, + resourceId: event.data.resourceId, + retryCount: retries, + }, + }; +} + +function generateRetriesExceededEvent( + event: PollableEvent, + retries: number, +): PDMResourceRetriesExceeded { + const eventTime = new Date().toISOString(); + + return { + ...event, + id: randomUUID(), + time: eventTime, + recordedtime: eventTime, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-retries-exceeded-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1', + data: { + messageReference: event.data.messageReference, + senderId: event.data.senderId, + resourceId: event.data.resourceId, + retryCount: retries, + }, + }; +} + +export const createHandler = ({ + eventPublisher, + logger, + pdm, + pollMaxRetries, +}: HandlerDependencies) => + async function handler(sqsEvent: SQSEvent): Promise { + const receivedItemCount = sqsEvent.Records.length; + const batchItemFailures: SQSBatchItemFailure[] = []; + const validatedRecords: ValidatedRecord[] = []; + const availableEvents: PDMResourceAvailable[] = []; + const unavailableEvents: PDMResourceUnavailable[] = []; + const retriesExceededEvents: PDMResourceRetriesExceeded[] = []; + + logger.info(`Received SQS Event of ${receivedItemCount} record(s)`); + + for (const record of sqsEvent.Records) { + const validated = validateRecord(record, logger); + if (validated) { + validatedRecords.push(validated); + } else { + batchItemFailures.push({ itemIdentifier: record.messageId }); + } + } + + await Promise.all( + validatedRecords.map(async (validatedRecord: ValidatedRecord) => { + try { + const { event } = validatedRecord; + const { nhsNumber, odsCode, pdmAvailability } = await pdm.poll(event); + let retries = 0; // First attempt for submitted events + if ('retryCount' in event.data) { + retries = event.data.retryCount + 1; // Increment attempt for unavailable events + } + + if (pdmAvailability === 'unavailable') { + if (retries >= pollMaxRetries) { + retriesExceededEvents.push( + generateRetriesExceededEvent(event, retries), + ); + } else { + unavailableEvents.push(generateUnavailableEvent(event, retries)); + } + } else { + availableEvents.push( + generateAvailableEvent(event, nhsNumber, odsCode), + ); + } + } catch (error: any) { + logger.warn({ + err: error.message, + description: 'Failed processing message', + }); + batchItemFailures.push({ itemIdentifier: validatedRecord.messageId }); + } + }), + ); + + await Promise.all( + [ + availableEvents.length > 0 && + eventPublisher.sendEvents( + availableEvents, + pdmResourceAvailableValidator, + ), + unavailableEvents.length > 0 && + eventPublisher.sendEvents( + unavailableEvents, + pdmResourceUnavailableValidator, + ), + retriesExceededEvents.length > 0 && + eventPublisher.sendEvents( + retriesExceededEvents, + pdmResourceRetriesExceededValidator, + ), + ].filter(Boolean), + ); + + const processedItemCount = receivedItemCount - batchItemFailures.length; + logger.info( + `${processedItemCount} of ${receivedItemCount} records processed successfully`, + ); + + return { batchItemFailures }; + }; diff --git a/lambdas/pdm-poll-lambda/src/app/pdm.ts b/lambdas/pdm-poll-lambda/src/app/pdm.ts new file mode 100644 index 00000000..a26e1e03 --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/app/pdm.ts @@ -0,0 +1,68 @@ +import { IPdmClient, Logger } from 'utils'; + +export type PdmAvailability = 'available' | 'unavailable'; + +export type PdmPollResult = { + pdmAvailability: PdmAvailability; + nhsNumber: string; + odsCode: string; +}; + +export interface PdmDependencies { + pdmClient: IPdmClient; + logger: Logger; +} + +export class Pdm { + private readonly pdmClient: IPdmClient; + + private readonly logger: Logger; + + constructor(config: PdmDependencies) { + if (!config.pdmClient) { + throw new Error('pdmClient has not been specified'); + } + if (!config.logger) { + throw new Error('logger has not been provided'); + } + + this.pdmClient = config.pdmClient; + this.logger = config.logger; + } + + async poll(item: any): Promise { + try { + this.logger.info(item); + + const response = await this.pdmClient.getDocumentReference( + item.data.resourceId, + item.data.messageReference, + ); + + const { data } = response.content[0].attachment; + const nhsNumber = response.subject.identifier.value; + const odsCode = response.author.find( + (author) => + author.identifier.system === + 'https://fhir.nhs.uk/Id/ods-organization-code', + )?.identifier.value; + + if (!odsCode) { + throw new Error('No ODS organization code found'); + } + + return { + pdmAvailability: data ? 'available' : 'unavailable', + nhsNumber, + odsCode, + }; + } catch (error) { + this.logger.error({ + description: 'Error getting document resource from PDM', + err: error, + }); + + throw error; + } + } +} diff --git a/lambdas/pdm-poll-lambda/src/container.ts b/lambdas/pdm-poll-lambda/src/container.ts new file mode 100644 index 00000000..d8514aa8 --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/container.ts @@ -0,0 +1,51 @@ +import { HandlerDependencies } from 'apis/sqs-handler'; +import { Pdm } from 'app/pdm'; +import { loadConfig } from 'infra/config'; +import { + EventPublisher, + ParameterStoreCache, + PdmClient, + createGetApimAccessToken, + eventBridgeClient, + logger, + sqsClient, +} from 'utils'; + +export const createContainer = (): HandlerDependencies => { + const { + apimAccessTokenSsmParameterName, + apimBaseUrl, + eventPublisherDlqUrl, + eventPublisherEventBusArn, + pollMaxRetries, + } = loadConfig(); + + const eventPublisher = new EventPublisher({ + eventBusArn: eventPublisherEventBusArn, + dlqUrl: eventPublisherDlqUrl, + logger, + sqsClient, + eventBridgeClient, + }); + + const parameterStore = new ParameterStoreCache(); + + const accessTokenRepository = { + getAccessToken: createGetApimAccessToken( + apimAccessTokenSsmParameterName, + logger, + parameterStore, + ), + }; + + const pdmClient = new PdmClient(apimBaseUrl, accessTokenRepository, logger); + + const pdm = new Pdm({ + pdmClient, + logger, + }); + + return { eventPublisher, logger, pdm, pollMaxRetries }; +}; + +export default createContainer; diff --git a/lambdas/pdm-poll-lambda/src/index.ts b/lambdas/pdm-poll-lambda/src/index.ts new file mode 100644 index 00000000..f25a8086 --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/index.ts @@ -0,0 +1,6 @@ +import { createHandler } from 'apis/sqs-handler'; +import { createContainer } from 'container'; + +export const handler = createHandler(createContainer()); + +export default handler; diff --git a/lambdas/pdm-poll-lambda/src/infra/config.ts b/lambdas/pdm-poll-lambda/src/infra/config.ts new file mode 100644 index 00000000..e40455ff --- /dev/null +++ b/lambdas/pdm-poll-lambda/src/infra/config.ts @@ -0,0 +1,25 @@ +import { defaultConfigReader } from 'utils'; + +export type PdmCreateConfig = { + apimBaseUrl: string; + pollMaxRetries: number; + apimAccessTokenSsmParameterName: string; + eventPublisherEventBusArn: string; + eventPublisherDlqUrl: string; +}; + +export function loadConfig(): PdmCreateConfig { + return { + apimBaseUrl: defaultConfigReader.getValue('APIM_BASE_URL'), + pollMaxRetries: defaultConfigReader.getInt('POLL_MAX_RETRIES'), + apimAccessTokenSsmParameterName: defaultConfigReader.getValue( + 'APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME', + ), + eventPublisherEventBusArn: defaultConfigReader.getValue( + 'EVENT_PUBLISHER_EVENT_BUS_ARN', + ), + eventPublisherDlqUrl: defaultConfigReader.getValue( + 'EVENT_PUBLISHER_DLQ_URL', + ), + }; +} diff --git a/lambdas/pdm-poll-lambda/tsconfig.json b/lambdas/pdm-poll-lambda/tsconfig.json new file mode 100644 index 00000000..f7bcaa1f --- /dev/null +++ b/lambdas/pdm-poll-lambda/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": "./src/", + "isolatedModules": true + }, + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/lambdas/pdm-uploader-lambda/src/__tests__/app/upload-to-pdm.test.ts b/lambdas/pdm-uploader-lambda/src/__tests__/app/upload-to-pdm.test.ts index 6365b9b3..3adae4dd 100644 --- a/lambdas/pdm-uploader-lambda/src/__tests__/app/upload-to-pdm.test.ts +++ b/lambdas/pdm-uploader-lambda/src/__tests__/app/upload-to-pdm.test.ts @@ -1,7 +1,6 @@ import { UploadToPdm } from 'app/upload-to-pdm'; import { MESHInboxMessageDownloaded } from 'digital-letters-events'; -import { IPdmClient } from 'infra/pdm-api-client'; -import { Logger, getS3ObjectFromUri } from 'utils'; +import { IPdmClient, Logger, getS3ObjectFromUri } from 'utils'; jest.mock('utils', () => ({ ...jest.requireActual('utils'), @@ -44,6 +43,14 @@ describe('UploadToPdm', () => { lastUpdated: '2023-06-20T12:00:00Z', }, status: 'current', + author: [ + { + identifier: { + system: 'https://fhir.nhs.uk/Id/ods-organization-code', + value: 'Y05868', + }, + }, + ], subject: { identifier: { system: 'https://fhir.nhs.uk/Id/nhs-number', diff --git a/lambdas/pdm-uploader-lambda/src/__tests__/container.test.ts b/lambdas/pdm-uploader-lambda/src/__tests__/container.test.ts index e671fbb7..de443d4a 100644 --- a/lambdas/pdm-uploader-lambda/src/__tests__/container.test.ts +++ b/lambdas/pdm-uploader-lambda/src/__tests__/container.test.ts @@ -14,12 +14,13 @@ jest.mock('app/upload-to-pdm', () => ({ })); jest.mock('utils', () => ({ - EventPublisher: jest.fn(() => ({})), + createGetApimAccessToken: jest.fn(() => ({})), eventBridgeClient: {}, + EventPublisher: jest.fn(() => ({})), logger: {}, - sqsClient: {}, ParameterStoreCache: jest.fn(() => ({})), - createGetApimAccessToken: jest.fn(() => ({})), + PdmClient: jest.fn(() => ({})), + sqsClient: {}, })); describe('container', () => { diff --git a/lambdas/pdm-uploader-lambda/src/__tests__/infra/pdm-api-client.test.ts b/lambdas/pdm-uploader-lambda/src/__tests__/infra/pdm-api-client.test.ts deleted file mode 100644 index 3c75a1eb..00000000 --- a/lambdas/pdm-uploader-lambda/src/__tests__/infra/pdm-api-client.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Logger } from 'utils'; -import axios from 'axios'; -import { constants as HTTP2_CONSTANTS } from 'node:http2'; -import { PdmClient } from 'infra/pdm-api-client'; - -jest.mock('axios'); -jest.mock('utils', () => ({ - ...jest.requireActual('utils'), - conditionalRetry: jest.fn(), -})); - -const mockedAxios = axios as jest.Mocked; -const { conditionalRetry } = jest.requireMock('utils'); - -describe('PdmClient', () => { - let pdmClient: PdmClient; - let mockAccessTokenRepository: any; - let mockLogger: jest.Mocked; - let mockAxiosInstance: any; - - beforeEach(() => { - jest.clearAllMocks(); - - mockLogger = { - debug: jest.fn(), - error: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - } as unknown as jest.Mocked; - - mockAccessTokenRepository = { - getAccessToken: jest.fn().mockResolvedValue('mock-access-token'), - }; - - mockAxiosInstance = { - post: jest.fn(), - head: jest.fn(), - }; - - mockedAxios.create.mockReturnValue(mockAxiosInstance); - mockedAxios.isAxiosError.mockImplementation( - (error: any) => error.isAxiosError === true, - ); - - pdmClient = new PdmClient( - 'https://api.example.com', - mockAccessTokenRepository, - mockLogger, - ); - }); - - describe('constructor', () => { - it('should create axios instance with base URL', () => { - expect(mockedAxios.create).toHaveBeenCalledWith({ - baseURL: 'https://api.example.com', - }); - }); - }); - - describe('createDocumentReference', () => { - const mockFhirRequest = JSON.stringify({ - resourceType: 'DocumentReference', - }); - const mockRequestId = 'req-123'; - - it('should successfully create document reference', async () => { - const mockResponse = { data: { id: 'doc-123' } }; - conditionalRetry.mockImplementation(async (fn: any) => fn(1)); - mockAxiosInstance.post.mockResolvedValue(mockResponse); - - const result = await pdmClient.createDocumentReference( - mockFhirRequest, - mockRequestId, - ); - - expect(mockAccessTokenRepository.getAccessToken).toHaveBeenCalled(); - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - '/patient-data-manager/FHIR/R4/DocumentReference', - mockFhirRequest, - { - headers: { - 'Content-Type': 'application/json', - 'X-Request-ID': mockRequestId, - Authorization: 'Bearer mock-access-token', - }, - }, - ); - expect(result).toEqual(mockResponse.data); - expect(mockLogger.debug).toHaveBeenCalledWith({ - requestId: mockRequestId, - description: 'Sending request', - attempt: 1, - }); - }); - - it('should handle empty access token', async () => { - mockAccessTokenRepository.getAccessToken.mockResolvedValue(''); - const mockResponse = { data: { id: 'doc-123' } }; - conditionalRetry.mockImplementation(async (fn: any) => fn(1)); - mockAxiosInstance.post.mockResolvedValue(mockResponse); - - await pdmClient.createDocumentReference(mockFhirRequest, mockRequestId); - - expect(mockAxiosInstance.post).toHaveBeenCalledWith( - '/patient-data-manager/FHIR/R4/DocumentReference', - mockFhirRequest, - { - headers: { - 'Content-Type': 'application/json', - 'X-Request-ID': mockRequestId, - }, - }, - ); - }); - - it('should retry on 429 error', async () => { - conditionalRetry.mockImplementation(async (fn: any, shouldRetry: any) => { - const error = { - isAxiosError: true, - response: { status: HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS }, - }; - expect(shouldRetry(error)).toBe(true); - - // Simulate successful retry - return fn(2); - }); - - const mockResponse = { data: { id: 'doc-123' } }; - mockAxiosInstance.post.mockResolvedValue(mockResponse); - - const result = await pdmClient.createDocumentReference( - mockFhirRequest, - mockRequestId, - ); - - expect(result).toEqual(mockResponse.data); - }); - - it('should not retry on other errors', async () => { - conditionalRetry.mockImplementation( - async (_fn: any, shouldRetry: any) => { - const error = { - isAxiosError: true, - response: { status: 500 }, - }; - expect(shouldRetry(error)).toBe(false); - }, - ); - - await pdmClient.createDocumentReference(mockFhirRequest, mockRequestId); - }); - - it('should log and throw error on failure', async () => { - const mockError = new Error('Network error'); - conditionalRetry.mockRejectedValue(mockError); - - await expect( - pdmClient.createDocumentReference(mockFhirRequest, mockRequestId), - ).rejects.toThrow('Network error'); - - expect(mockLogger.error).toHaveBeenCalledWith({ - description: 'Failed sending PDM request', - requestId: mockRequestId, - err: mockError, - }); - }); - }); -}); diff --git a/lambdas/pdm-uploader-lambda/src/app/upload-to-pdm.ts b/lambdas/pdm-uploader-lambda/src/app/upload-to-pdm.ts index 058fb8e1..3de0cdca 100644 --- a/lambdas/pdm-uploader-lambda/src/app/upload-to-pdm.ts +++ b/lambdas/pdm-uploader-lambda/src/app/upload-to-pdm.ts @@ -1,5 +1,4 @@ -import { Logger, getS3ObjectFromUri } from 'utils'; -import { IPdmClient } from 'infra/pdm-api-client'; +import { IPdmClient, Logger, getS3ObjectFromUri } from 'utils'; import { MESHInboxMessageDownloaded } from 'digital-letters-events'; export type UploadToPdmOutcome = 'sent' | 'failed'; diff --git a/lambdas/pdm-uploader-lambda/src/container.ts b/lambdas/pdm-uploader-lambda/src/container.ts index b9915a96..bdca7a87 100644 --- a/lambdas/pdm-uploader-lambda/src/container.ts +++ b/lambdas/pdm-uploader-lambda/src/container.ts @@ -1,6 +1,7 @@ import { EventPublisher, ParameterStoreCache, + PdmClient, createGetApimAccessToken, eventBridgeClient, logger, @@ -8,7 +9,6 @@ import { } from 'utils'; import { loadConfig } from 'infra/config'; import { UploadToPdm } from 'app/upload-to-pdm'; -import { PdmClient } from 'infra/pdm-api-client'; export const createContainer = () => { const { diff --git a/lambdas/refresh-apim-access-token/package.json b/lambdas/refresh-apim-access-token/package.json index 766771a6..59450065 100644 --- a/lambdas/refresh-apim-access-token/package.json +++ b/lambdas/refresh-apim-access-token/package.json @@ -2,7 +2,7 @@ "dependencies": { "@aws-sdk/client-ssm": "^3.840.0", "aws-lambda": "^1.0.7", - "axios": "1.12.0", + "axios": "^1.13.2", "esbuild": "^0.25.9", "jsonwebtoken": "^9.0.2", "qs": "^6.14.1", diff --git a/package-lock.json b/package-lock.json index f7a440f1..5180598a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "lambdas/refresh-apim-access-token", "lambdas/mesh-poll", "lambdas/pdm-mock-lambda", + "lambdas/pdm-poll-lambda", "lambdas/ttl-create-lambda", "lambdas/ttl-handle-expiry-lambda", "lambdas/ttl-poll-lambda", @@ -283,11 +284,11 @@ "dev": true, "license": "MIT" }, - "lambdas/pdm-uploader-lambda": { - "name": "nhs-notify-digital-letters-pdm-uploader", + "lambdas/pdm-poll-lambda": { + "name": "nhs-notify-digital-letters-pdm-poll-lambda", "version": "0.0.1", "dependencies": { - "axios": "^1.13.2", + "aws-lambda": "^1.0.7", "digital-letters-events": "^0.0.1", "utils": "^0.0.1" }, @@ -296,10 +297,11 @@ "@types/aws-lambda": "^8.10.155", "@types/jest": "^29.5.14", "jest": "^29.7.0", + "jest-mock-extended": "^3.0.7", "typescript": "^5.9.3" } }, - "lambdas/pdm-uploader-lambda/node_modules/@types/jest": { + "lambdas/pdm-poll-lambda/node_modules/@types/jest": { "version": "29.5.14", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", @@ -310,15 +312,73 @@ "pretty-format": "^29.0.0" } }, - "lambdas/pdm-uploader-lambda/node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "lambdas/pdm-poll-lambda/node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "lambdas/pdm-poll-lambda/node_modules/jest-mock-extended": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz", + "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-essentials": "^10.0.0" + }, + "peerDependencies": { + "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0", + "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "lambdas/pdm-uploader-lambda": { + "name": "nhs-notify-digital-letters-pdm-uploader", + "version": "0.0.1", + "dependencies": { + "axios": "^1.13.2", + "digital-letters-events": "^0.0.1", + "utils": "^0.0.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/aws-lambda": "^8.10.155", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "typescript": "^5.9.3" + } + }, + "lambdas/pdm-uploader-lambda/node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" } }, "lambdas/pdm-uploader-lambda/node_modules/jest": { @@ -348,12 +408,34 @@ } } }, + "lambdas/poll-pdm-lambda": { + "name": "nhs-notify-digital-letters-poll-pdm-lambda", + "version": "0.0.1", + "extraneous": true, + "dependencies": { + "aws-lambda": "^1.0.7", + "lodash": "^4.17.21", + "p-limit": "^3.1.0", + "utils": "^0.0.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/aws-lambda": "^8.10.155", + "@types/jest": "^29.5.14", + "@types/lodash": "^4.17.20", + "aws-sdk-client-mock": "^4.1.0", + "aws-sdk-client-mock-jest": "^4.1.0", + "jest": "^29.7.0", + "jest-mock-extended": "^3.0.7", + "typescript": "^5.9.3" + } + }, "lambdas/refresh-apim-access-token": { "version": "0.0.1", "dependencies": { "@aws-sdk/client-ssm": "^3.840.0", "aws-lambda": "^1.0.7", - "axios": "1.12.0", + "axios": "^1.13.2", "esbuild": "^0.25.9", "jsonwebtoken": "^9.0.2", "qs": "^6.14.1", @@ -7271,9 +7353,9 @@ } }, "node_modules/axios": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", - "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -14754,6 +14836,10 @@ "resolved": "lambdas/pdm-mock-lambda", "link": true }, + "node_modules/nhs-notify-digital-letters-pdm-poll-lambda": { + "resolved": "lambdas/pdm-poll-lambda", + "link": true + }, "node_modules/nhs-notify-digital-letters-pdm-uploader": { "resolved": "lambdas/pdm-uploader-lambda", "link": true @@ -18386,7 +18472,7 @@ "ajv-formats": "^3.0.1", "fast-glob": "^3.3.2", "js-yaml": "^4.1.0", - "json-schema-faker": "^0.5.0-rc23", + "json-schema-faker": "^0.5.9", "json-schema-ref-parser": "^9.0.9" }, "devDependencies": { @@ -18837,6 +18923,7 @@ "@aws-sdk/lib-dynamodb": "^3.914.0", "@aws-sdk/lib-storage": "^3.914.0", "async-mutex": "^0.4.0", + "axios": "^1.13.2", "date-fns": "^4.1.0", "node-jose": "^2.2.0", "winston": "^3.17.0", diff --git a/package.json b/package.json index 27c15e9e..d993bbda 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "lambdas/refresh-apim-access-token", "lambdas/mesh-poll", "lambdas/pdm-mock-lambda", + "lambdas/pdm-poll-lambda", "lambdas/ttl-create-lambda", "lambdas/ttl-handle-expiry-lambda", "lambdas/ttl-poll-lambda", diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml index ccf3a2a9..a1804697 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/defs/pdm.schema.yaml @@ -11,7 +11,7 @@ properties: - "f5524783-e5d7-473e-b2a0-29582ff231da" retryCount: type: integer - minimum: 1 - description: Number of times that PDM has been polled while waiting for document processing to complete + minimum: 0 + description: Number of times that PDM has been retried while waiting for document processing to complete examples: - 2 diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1.schema.yaml index 58d83a0b..ccbeb08b 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/events/uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1.schema.yaml @@ -15,9 +15,9 @@ properties: dataschema: type: string - const: ../data/digital-letters-pdm-resource-retries-exceeded-data.yaml + const: ../data/digital-letters-pdm-resource-retries-exceeded-data.schema.yaml description: Canonical URI of the event's data schema. examples: - - digital-letters-pdm-resource-retries-exceeded-data.yaml + - digital-letters-pdm-resource-retries-exceeded-data.schema.yaml data: $ref: ../data/digital-letters-pdm-resource-retries-exceeded-data.schema.yaml diff --git a/src/cloudevents/package.json b/src/cloudevents/package.json index 173c2afa..798398a6 100644 --- a/src/cloudevents/package.json +++ b/src/cloudevents/package.json @@ -5,7 +5,7 @@ "ajv-formats": "^3.0.1", "fast-glob": "^3.3.2", "js-yaml": "^4.1.0", - "json-schema-faker": "^0.5.0-rc23", + "json-schema-faker": "^0.5.9", "json-schema-ref-parser": "^9.0.9" }, "devDependencies": { diff --git a/tests/playwright/constants/backend-constants.ts b/tests/playwright/constants/backend-constants.ts index a8f30c46..fd2fae57 100644 --- a/tests/playwright/constants/backend-constants.ts +++ b/tests/playwright/constants/backend-constants.ts @@ -16,6 +16,7 @@ export const TTL_POLL_LAMBDA_NAME = `${CSI}-ttl-poll`; export const TTL_QUEUE_NAME = `${CSI}-ttl-queue`; export const TTL_DLQ_NAME = `${CSI}-ttl-dlq`; export const PDM_UPLOADER_DLQ_NAME = `${CSI}-pdm-uploader-dlq`; +export const PDM_POLL_DLQ_NAME = `${CSI}-pdm-poll-dlq`; // Queue Url Prefix export const SQS_URL_PREFIX = `https://sqs.${REGION}.amazonaws.com/${AWS_ACCOUNT_ID}/`; @@ -33,3 +34,4 @@ export const LETTERS_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REGIO // Cloudwatch export const PDM_UPLOADER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-uploader`; +export const PDM_POLL_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-poll`; diff --git a/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts new file mode 100644 index 00000000..fe5e001b --- /dev/null +++ b/tests/playwright/digital-letters-component-tests/pdm-poll.component.spec.ts @@ -0,0 +1,278 @@ +import { expect, test } from '@playwright/test'; +import { + EVENT_BUS_LOG_GROUP_NAME, + PDM_POLL_DLQ_NAME, + PDM_POLL_LAMBDA_LOG_GROUP_NAME, +} from 'constants/backend-constants'; +import pdmResourceSubmittedValidator from 'digital-letters-events/PDMResourceSubmitted.js'; +import pdmResourceUnavailableValidator from 'digital-letters-events/PDMResourceUnavailable.js'; +import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; +import eventPublisher from 'helpers/event-bus-helpers'; +import expectToPassEventually from 'helpers/expectations'; +import { expectMessageContainingString } from 'helpers/sqs-helpers'; +import { v4 as uuidv4 } from 'uuid'; + +const baseEvent = { + specversion: '1.0', + source: + '/nhs/england/notify/production/primary/data-plane/digitalletters/pdm', + subject: + 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', + time: '2023-06-20T12:00:00Z', + recordedtime: '2023-06-20T12:00:00.250Z', + severitynumber: 2, + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + datacontenttype: 'application/json', + severitytext: 'INFO', +}; + +const submittedEvent = { + ...baseEvent, + type: 'uk.nhs.notify.digital.letters.pdm.resource.submitted.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-submitted-data.schema.json', +}; + +const unavailableEvent = { + ...baseEvent, + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', +}; + +test.describe('PDM Poll', () => { + test.beforeAll(async () => { + test.setTimeout(250_000); + }); + + test.describe('pdm.resource.submitted', () => { + test('should send a pdm.resource.available event when available in PDM', async () => { + const eventId = uuidv4(); + const resourceId = '9ae75410-c067-35ae-9410-153fa849a4dd'; + const messageReference = uuidv4(); + const senderId = uuidv4(); + + await eventPublisher.sendEvents( + [ + { + ...submittedEvent, + id: eventId, + data: { + resourceId, + messageReference, + senderId, + }, + }, + ], + pdmResourceSubmittedValidator, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.available.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"odsCode\\":\\"Y05868\\"*"`, + `$.details.event_detail = "*\\"nhsNumber\\":\\"9912003071\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); + + test('should send a pdm.resource.unavailable event when unavailable in PDM', async () => { + const eventId = uuidv4(); + const resourceId = 'unavailable-response'; + const messageReference = uuidv4(); + const senderId = uuidv4(); + + await eventPublisher.sendEvents( + [ + { + ...submittedEvent, + id: eventId, + data: { + resourceId, + messageReference, + senderId, + }, + }, + ], + pdmResourceSubmittedValidator, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"retryCount\\":0*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); + }); + + test.describe('pdm.resource.unavailable', () => { + test('should send a pdm.resource.available event when an unavailable resource becomes available in PDM', async () => { + const eventId = uuidv4(); + const resourceId = uuidv4(); + const messageReference = uuidv4(); + const senderId = uuidv4(); + + await eventPublisher.sendEvents( + [ + { + ...unavailableEvent, + id: eventId, + data: { + resourceId, + messageReference, + senderId, + retryCount: 0, + }, + }, + ], + pdmResourceUnavailableValidator, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.available.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"odsCode\\":\\"Y05868\\"*"`, + `$.details.event_detail = "*\\"nhsNumber\\":\\"9912003071\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); + + test('should send a pdm.resource.unavailable event when still unavailable in PDM', async () => { + const eventId = uuidv4(); + const resourceId = 'unavailable-response'; + const messageReference = uuidv4(); + const senderId = uuidv4(); + + await eventPublisher.sendEvents( + [ + { + ...unavailableEvent, + id: eventId, + data: { + resourceId, + messageReference, + senderId, + retryCount: 0, + }, + }, + ], + pdmResourceUnavailableValidator, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"retryCount\\":1*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); + + test('should send a pdm.resource.retries.exceeded event when unavailable in PDM after 10 retries', async () => { + const eventId = uuidv4(); + const resourceId = 'unavailable-response'; + const messageReference = uuidv4(); + const senderId = uuidv4(); + + await eventPublisher.sendEvents( + [ + { + ...unavailableEvent, + id: eventId, + data: { + resourceId, + messageReference, + senderId, + retryCount: 9, + }, + }, + ], + pdmResourceUnavailableValidator, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.pdm.resource.retries.exceeded.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"retryCount\\":10*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + }); + }); + + test('should send invalid event to poll dlq', async () => { + // Sadly it takes longer than expected to go through the 3 retries before it's sent to the DLQ. + test.setTimeout(550_000); + + const eventId = uuidv4(); + const resourceId = 'b8f2b194-31e1-3719-aaf9-a9195e35e692'; + const messageReference = uuidv4(); + const senderId = uuidv4(); + + // Send pdm.resource.unavailable event with no retryCount + await eventPublisher.sendEvents( + [ + { + ...baseEvent, + id: eventId, + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-pdm-resource-unavailable-data.schema.json', + type: 'uk.nhs.notify.digital.letters.pdm.resource.unavailable.v1', + data: { + resourceId, + messageReference, + senderId, + }, + }, + ], + () => true, + ); + + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + PDM_POLL_LAMBDA_LOG_GROUP_NAME, + [ + `$.message.err[0].message = "must have required property 'retryCount'"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 120); + + await expectMessageContainingString(PDM_POLL_DLQ_NAME, eventId, 420); + }); +}); diff --git a/tests/playwright/digital-letters-component-tests/pdm-uploader.component.spec.ts b/tests/playwright/digital-letters-component-tests/pdm-uploader.component.spec.ts index ee734c2e..5a2d99c4 100644 --- a/tests/playwright/digital-letters-component-tests/pdm-uploader.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/pdm-uploader.component.spec.ts @@ -34,8 +34,6 @@ const pdmRequest = { }; const baseEvent = { - profileversion: '1.0.0', - profilepublished: '2025-10', specversion: '1.0', source: '/nhs/england/notify/production/primary/data-plane/digitalletters/mesh', @@ -49,7 +47,6 @@ const baseEvent = { datacontenttype: 'application/json', dataschema: 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json', - dataschemaversion: '1.0', severitytext: 'INFO', }; @@ -170,7 +167,7 @@ test.describe('Digital Letters - Upload to PDM', () => { }, 120); }); - test('should send invalid event to dlq', async () => { + test('should send invalid event to uploader dlq', async () => { // Sadly it takes longer than expected to go through the 3 retries before it's sent to the DLQ. test.setTimeout(550_000); diff --git a/utils/utils/package.json b/utils/utils/package.json index 112ae9bb..5669cae8 100644 --- a/utils/utils/package.json +++ b/utils/utils/package.json @@ -9,6 +9,7 @@ "@aws-sdk/lib-dynamodb": "^3.914.0", "@aws-sdk/lib-storage": "^3.914.0", "async-mutex": "^0.4.0", + "axios": "^1.13.2", "date-fns": "^4.1.0", "node-jose": "^2.2.0", "winston": "^3.17.0", diff --git a/utils/utils/src/__tests__/pdm-client/pdm-client.test.ts b/utils/utils/src/__tests__/pdm-client/pdm-client.test.ts new file mode 100644 index 00000000..aa7858f8 --- /dev/null +++ b/utils/utils/src/__tests__/pdm-client/pdm-client.test.ts @@ -0,0 +1,343 @@ +import axios from 'axios'; +import { constants as HTTP2_CONSTANTS } from 'node:http2'; +import { Logger } from '../../logger'; +import { PdmClient } from '../../pdm-client'; + +jest.mock('axios'); + +const mockedAxios = axios as jest.Mocked; + +describe('PdmClient', () => { + let pdmClient: PdmClient; + let mockAccessTokenRepository: { getAccessToken: jest.Mock }; + let mockLogger: jest.Mocked; + let mockAxiosInstance: { + get: jest.Mock; + head: jest.Mock; + post: jest.Mock; + }; + const mockDocumentResourceId = 'doc-123'; + const mockResponse = { data: { id: mockDocumentResourceId } }; + const mockRequestId = 'req-123'; + + beforeEach(() => { + jest.clearAllMocks(); + + mockLogger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + } as unknown as jest.Mocked; + + mockAccessTokenRepository = { + getAccessToken: jest.fn().mockResolvedValue('mock-access-token'), + }; + + mockAxiosInstance = { + get: jest.fn(), + head: jest.fn(), + post: jest.fn(), + }; + + mockedAxios.create.mockReturnValue(mockAxiosInstance as any); + mockedAxios.isAxiosError.mockImplementation( + (error: any) => error.isAxiosError === true, + ); + + pdmClient = new PdmClient( + 'https://api.example.com', + mockAccessTokenRepository, + mockLogger, + ); + }); + + describe('constructor', () => { + it('should create axios instance with base URL', () => { + expect(mockedAxios.create).toHaveBeenCalledWith({ + baseURL: 'https://api.example.com', + }); + }); + }); + + describe('createDocumentReference', () => { + const mockFhirRequest = JSON.stringify({ + resourceType: 'DocumentReference', + }); + + it('should successfully create document reference', async () => { + mockAxiosInstance.post.mockResolvedValue(mockResponse); + + const result = await pdmClient.createDocumentReference( + mockFhirRequest, + mockRequestId, + ); + + expect(result).toEqual(mockResponse.data); + expect(mockAccessTokenRepository.getAccessToken).toHaveBeenCalledTimes(1); + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + '/patient-data-manager/FHIR/R4/DocumentReference', + mockFhirRequest, + { + headers: { + 'Content-Type': 'application/json', + 'X-Request-ID': mockRequestId, + Authorization: 'Bearer mock-access-token', + }, + }, + ); + expect(mockLogger.debug).toHaveBeenCalledWith({ + requestId: mockRequestId, + description: 'Sending request', + attempt: 1, + }); + }); + + it('should omit Authorization header when access token is empty', async () => { + mockAccessTokenRepository.getAccessToken.mockResolvedValue(''); + mockAxiosInstance.post.mockResolvedValue(mockResponse); + + await pdmClient.createDocumentReference(mockFhirRequest, mockRequestId); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + '/patient-data-manager/FHIR/R4/DocumentReference', + mockFhirRequest, + { + headers: { + 'Content-Type': 'application/json', + 'X-Request-ID': mockRequestId, + }, + }, + ); + }); + + it('should retry on 429 rate limit errors', async () => { + const mockError = { + isAxiosError: true, + response: { status: HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS }, + }; + + mockAxiosInstance.post + .mockRejectedValueOnce(mockError) + .mockRejectedValueOnce(mockError) + .mockResolvedValueOnce(mockResponse); + + const result = await pdmClient.createDocumentReference( + mockFhirRequest, + mockRequestId, + ); + + expect(result).toEqual(mockResponse.data); + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(3); + expect(mockLogger.debug).toHaveBeenCalledTimes(3); + expect(mockLogger.debug).toHaveBeenNthCalledWith(1, { + requestId: mockRequestId, + description: 'Sending request', + attempt: 1, + }); + expect(mockLogger.debug).toHaveBeenNthCalledWith(3, { + requestId: mockRequestId, + description: 'Sending request', + attempt: 3, + }); + }); + + it('should not retry on 500 server errors', async () => { + const mockError = { + isAxiosError: true, + response: { status: 500 }, + }; + mockAxiosInstance.post.mockRejectedValue(mockError); + + await expect( + pdmClient.createDocumentReference(mockFhirRequest, mockRequestId), + ).rejects.toEqual(mockError); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith({ + description: 'Failed sending PDM request', + requestId: mockRequestId, + err: mockError, + }); + }); + + it('should not retry on non-axios errors', async () => { + const mockError = new Error('Network error'); + mockAxiosInstance.post.mockRejectedValue(mockError); + + await expect( + pdmClient.createDocumentReference(mockFhirRequest, mockRequestId), + ).rejects.toThrow('Network error'); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith({ + description: 'Failed sending PDM request', + requestId: mockRequestId, + err: mockError, + }); + }); + + it('should respect maxAttempts in retry config', async () => { + const mockError = { + isAxiosError: true, + response: { status: HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS }, + }; + mockAxiosInstance.post.mockRejectedValue(mockError); + + const pdmClientWithCustomRetry = new PdmClient( + 'https://api.example.com', + mockAccessTokenRepository, + mockLogger, + { maxDelayMs: 100, intervalMs: 10, exponentialRate: 1, maxAttempts: 3 }, + ); + + await expect( + pdmClientWithCustomRetry.createDocumentReference( + mockFhirRequest, + mockRequestId, + ), + ).rejects.toEqual(mockError); + + // Should attempt 3 times then give up + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(3); + }); + }); + + describe('getDocumentReference', () => { + it('should successfully fetch document reference', async () => { + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + const result = await pdmClient.getDocumentReference( + mockDocumentResourceId, + mockRequestId, + ); + + expect(result).toEqual(mockResponse.data); + expect(mockAccessTokenRepository.getAccessToken).toHaveBeenCalledTimes(1); + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + `/patient-data-manager/FHIR/R4/DocumentReference/${mockDocumentResourceId}`, + { + headers: { + 'X-Request-ID': mockRequestId, + Authorization: 'Bearer mock-access-token', + }, + }, + ); + expect(mockLogger.debug).toHaveBeenCalledWith({ + requestId: mockRequestId, + description: 'Sending request', + attempt: 1, + }); + }); + + it('should omit Authorization header when access token is empty', async () => { + mockAccessTokenRepository.getAccessToken.mockResolvedValue(''); + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + await pdmClient.getDocumentReference( + mockDocumentResourceId, + mockRequestId, + ); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + `/patient-data-manager/FHIR/R4/DocumentReference/${mockDocumentResourceId}`, + { + headers: { + 'X-Request-ID': mockRequestId, + }, + }, + ); + }); + + it('should retry on 429 rate limit errors', async () => { + const mockError = { + isAxiosError: true, + response: { status: HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS }, + }; + + mockAxiosInstance.get + .mockRejectedValueOnce(mockError) + .mockRejectedValueOnce(mockError) + .mockResolvedValueOnce(mockResponse); + + const result = await pdmClient.getDocumentReference( + mockDocumentResourceId, + mockRequestId, + ); + + expect(result).toEqual(mockResponse.data); + expect(mockAxiosInstance.get).toHaveBeenCalledTimes(3); + expect(mockLogger.debug).toHaveBeenCalledTimes(3); + expect(mockLogger.debug).toHaveBeenNthCalledWith(1, { + requestId: mockRequestId, + description: 'Sending request', + attempt: 1, + }); + expect(mockLogger.debug).toHaveBeenNthCalledWith(3, { + requestId: mockRequestId, + description: 'Sending request', + attempt: 3, + }); + }); + + it('should not retry on 500 server errors', async () => { + const mockError = { + isAxiosError: true, + response: { status: 500 }, + }; + mockAxiosInstance.get.mockRejectedValue(mockError); + + await expect( + pdmClient.getDocumentReference(mockDocumentResourceId, mockRequestId), + ).rejects.toEqual(mockError); + + expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith({ + description: 'Failed sending PDM request', + requestId: mockRequestId, + err: mockError, + }); + }); + + it('should not retry on non-axios errors', async () => { + const mockError = new Error('Network error'); + mockAxiosInstance.get.mockRejectedValue(mockError); + + await expect( + pdmClient.getDocumentReference(mockDocumentResourceId, mockRequestId), + ).rejects.toThrow('Network error'); + + expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith({ + description: 'Failed sending PDM request', + requestId: mockRequestId, + err: mockError, + }); + }); + + it('should respect maxAttempts in retry config', async () => { + const mockError = { + isAxiosError: true, + response: { status: HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS }, + }; + mockAxiosInstance.get.mockRejectedValue(mockError); + + const pdmClientWithCustomRetry = new PdmClient( + 'https://api.example.com', + mockAccessTokenRepository, + mockLogger, + { maxDelayMs: 100, intervalMs: 10, exponentialRate: 1, maxAttempts: 3 }, + ); + + await expect( + pdmClientWithCustomRetry.getDocumentReference( + mockDocumentResourceId, + mockRequestId, + ), + ).rejects.toEqual(mockError); + + // Should attempt 3 times then give up + expect(mockAxiosInstance.get).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/utils/utils/src/index.ts b/utils/utils/src/index.ts index 46ef7231..8da5a845 100644 --- a/utils/utils/src/index.ts +++ b/utils/utils/src/index.ts @@ -14,3 +14,4 @@ export * from './event-publisher'; export * from './event-bridge-utils'; export * from './key-generation-utils'; export * from './schema-utils'; +export * from './pdm-client'; diff --git a/utils/utils/src/pdm-client/index.ts b/utils/utils/src/pdm-client/index.ts new file mode 100644 index 00000000..211c2b7c --- /dev/null +++ b/utils/utils/src/pdm-client/index.ts @@ -0,0 +1 @@ +export * from './pdm-client'; diff --git a/lambdas/pdm-uploader-lambda/src/infra/pdm-api-client.ts b/utils/utils/src/pdm-client/pdm-client.ts similarity index 58% rename from lambdas/pdm-uploader-lambda/src/infra/pdm-api-client.ts rename to utils/utils/src/pdm-client/pdm-client.ts index c7fb997c..7b50fc3d 100644 --- a/lambdas/pdm-uploader-lambda/src/infra/pdm-api-client.ts +++ b/utils/utils/src/pdm-client/pdm-client.ts @@ -1,6 +1,8 @@ import axios, { AxiosInstance, isAxiosError } from 'axios'; import { constants as HTTP2_CONSTANTS } from 'node:http2'; -import { Logger, PdmResponse, RetryConfig, conditionalRetry } from 'utils'; +import { Logger } from '../logger'; +import { PdmResponse } from '../types'; +import { RetryConfig, conditionalRetry } from '../util-retry'; export interface IAccessTokenRepository { getAccessToken(): Promise; @@ -11,6 +13,10 @@ export interface IPdmClient { fhirRequest: string, requestId: string, ): Promise; + getDocumentReference( + documentResourceId: string, + requestId: string, + ): Promise; } export class PdmClient implements IPdmClient { @@ -82,4 +88,53 @@ export class PdmClient implements IPdmClient { throw error; } } + + public async getDocumentReference( + documentResourceId: string, + requestId: string, + ): Promise { + try { + return await conditionalRetry( + async (attempt) => { + const accessToken = await this.accessTokenRepository.getAccessToken(); + + this.logger.debug({ + requestId, + description: 'Sending request', + attempt, + }); + + const headers = { + 'X-Request-ID': requestId, + ...(accessToken === '' + ? {} + : { + Authorization: `Bearer ${accessToken}`, + }), + }; + const response = await this.client.get( + `/patient-data-manager/FHIR/R4/DocumentReference/${documentResourceId}`, + { headers }, + ); + + return response.data; + }, + (err) => + Boolean( + isAxiosError(err) && + err.response?.status === + HTTP2_CONSTANTS.HTTP_STATUS_TOO_MANY_REQUESTS, + ), + this.backoffConfig, + ); + } catch (error: any) { + this.logger.error({ + description: 'Failed sending PDM request', + requestId, + err: error, + }); + + throw error; + } + } } diff --git a/utils/utils/src/types/pdm-types.ts b/utils/utils/src/types/pdm-types.ts index 28c112b5..ff1dbf29 100644 --- a/utils/utils/src/types/pdm-types.ts +++ b/utils/utils/src/types/pdm-types.ts @@ -16,6 +16,13 @@ export type PdmResponse = { attachment: { contentType: string; title: string; + data?: string; + }; + }[]; + author: { + identifier: { + system: string; + value: string; }; }[]; };