diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md
index d15e7b28..133f5833 100644
--- a/infrastructure/terraform/components/dl/README.md
+++ b/infrastructure/terraform/components/dl/README.md
@@ -16,6 +16,7 @@ No requirements.
| [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes |
| [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"dl"` | no |
| [core\_notify\_url](#input\_core\_notify\_url) | The URL used to send requests to Notify | `string` | `"https://sandbox.api.service.nhs.uk"` | no |
+| [default\_cloudwatch\_event\_bus\_name](#input\_default\_cloudwatch\_event\_bus\_name) | The name of the default cloudwatch event bus. This is needed as GuardDuty Scan Result events are sent to the default bus | `string` | `"default"` | no |
| [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no |
| [enable\_dynamodb\_delete\_protection](#input\_enable\_dynamodb\_delete\_protection) | Enable DynamoDB Delete Protection on all Tables | `bool` | `true` | no |
| [enable\_mock\_mesh](#input\_enable\_mock\_mesh) | Enable mock mesh access (dev only). Grants lambda permission to read mock-mesh prefix in non-pii bucket. | `bool` | `false` | no |
@@ -49,29 +50,32 @@ 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\_download](#module\_mesh\_download) | 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 |
+| [move\_scanned\_files](#module\_move\_scanned\_files) | 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 |
| [print\_analyser](#module\_print\_analyser) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [print\_status\_handler](#module\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [report\_event\_transformer](#module\_report\_event\_transformer) | 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\_file\_safe](#module\_s3bucket\_file\_safe) | 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\_non\_pii\_data](#module\_s3bucket\_non\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
-| [s3bucket\_pii\_data](#module\_s3bucket\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a |
-| [s3bucket\_reporting](#module\_s3bucket\_reporting) | 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\_core\_notifier](#module\_sqs\_core\_notifier) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.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\_mesh\_download](#module\_sqs\_mesh\_download) | 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 |
+| [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a |
+| [s3bucket\_file\_quarantine](#module\_s3bucket\_file\_quarantine) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a |
+| [s3bucket\_file\_safe](#module\_s3bucket\_file\_safe) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a |
+| [s3bucket\_letters](#module\_s3bucket\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a |
+| [s3bucket\_non\_pii\_data](#module\_s3bucket\_non\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a |
+| [s3bucket\_pii\_data](#module\_s3bucket\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a |
+| [s3bucket\_reporting](#module\_s3bucket\_reporting) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a |
+| [s3bucket\_static\_assets](#module\_s3bucket\_static\_assets) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a |
+| [sqs\_core\_notifier](#module\_sqs\_core\_notifier) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a |
+| [sqs\_event\_publisher\_errors](#module\_sqs\_event\_publisher\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a |
+| [sqs\_mesh\_download](#module\_sqs\_mesh\_download) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a |
+| [sqs\_move\_scanned\_files](#module\_sqs\_move\_scanned\_files) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a |
+| [sqs\_pdm\_poll](#module\_sqs\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a |
+| [sqs\_pdm\_uploader](#module\_sqs\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a |
| [sqs\_print\_analyser](#module\_sqs\_print\_analyser) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a |
| [sqs\_print\_status\_handler](#module\_sqs\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a |
-| [sqs\_scanner](#module\_sqs\_scanner) | 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 |
+| [sqs\_scanner](#module\_sqs\_scanner) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a |
+| [sqs\_ttl](#module\_sqs\_ttl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/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.30/terraform-sqs.zip | n/a |
| [ttl\_create](#module\_ttl\_create) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [ttl\_handle\_expiry](#module\_ttl\_handle\_expiry) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
| [ttl\_poll](#module\_ttl\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a |
diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_guardduty_scan_result.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_guardduty_scan_result.tf
new file mode 100644
index 00000000..80a9daf5
--- /dev/null
+++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_guardduty_scan_result.tf
@@ -0,0 +1,21 @@
+resource "aws_cloudwatch_event_rule" "guardduty_scan_result" {
+ name = "${local.csi}-guardduty_scan_result"
+ description = "guardduty Scan Result event rule"
+ event_bus_name = var.default_cloudwatch_event_bus_name
+ event_pattern = jsonencode({
+ "source" : ["aws.guardduty"]
+ "detail" : {
+ "resourceType" : ["S3_OBJECT"],
+ "s3ObjectDetails" : {
+ "bucketName" : [local.unscanned_files_bucket],
+ "objectKey" : [{ "prefix" : "${local.csi}/" }]
+ }
+ }
+ })
+}
+
+resource "aws_cloudwatch_event_target" "guardduty_scan_result_move_scanned_files" {
+ rule = aws_cloudwatch_event_rule.guardduty_scan_result.name
+ arn = module.sqs_move_scanned_files.sqs_queue_arn
+ event_bus_name = var.default_cloudwatch_event_bus_name
+}
diff --git a/infrastructure/terraform/components/dl/lambda_event_source_mapping_move_scanned_files_lambda.tf b/infrastructure/terraform/components/dl/lambda_event_source_mapping_move_scanned_files_lambda.tf
new file mode 100644
index 00000000..df19cb21
--- /dev/null
+++ b/infrastructure/terraform/components/dl/lambda_event_source_mapping_move_scanned_files_lambda.tf
@@ -0,0 +1,10 @@
+resource "aws_lambda_event_source_mapping" "move_scanned_files_lambda" {
+ event_source_arn = module.sqs_move_scanned_files.sqs_queue_arn
+ function_name = module.move_scanned_files.function_arn
+ 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_file_scanner.tf b/infrastructure/terraform/components/dl/module_lambda_file_scanner.tf
index 87755068..b942dda5 100644
--- a/infrastructure/terraform/components/dl/module_lambda_file_scanner.tf
+++ b/infrastructure/terraform/components/dl/module_lambda_file_scanner.tf
@@ -35,11 +35,11 @@ module "file_scanner" {
log_subscription_role_arn = local.acct.log_subscription_role_arn
lambda_env_vars = {
- "DOCUMENT_REFERENCE_BUCKET" = module.s3bucket_pii_data.bucket
- "UNSCANNED_FILES_BUCKET" = local.unscanned_files_bucket
- "UNSCANNED_FILES_PATH_PREFIX" = var.environment
+ "DOCUMENT_REFERENCE_BUCKET" = module.s3bucket_pii_data.bucket
+ "UNSCANNED_FILES_BUCKET" = local.unscanned_files_bucket
+ "UNSCANNED_FILES_PATH_PREFIX" = var.environment
"EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn
- "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url
+ "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url
}
}
diff --git a/infrastructure/terraform/components/dl/module_lambda_mesh_poll.tf b/infrastructure/terraform/components/dl/module_lambda_mesh_poll.tf
index 4fd2076f..7b96ea55 100644
--- a/infrastructure/terraform/components/dl/module_lambda_mesh_poll.tf
+++ b/infrastructure/terraform/components/dl/module_lambda_mesh_poll.tf
@@ -42,7 +42,7 @@ module "mesh_poll" {
ENVIRONMENT = var.environment
EVENT_PUBLISHER_DLQ_URL = module.sqs_event_publisher_errors.sqs_queue_url
EVENT_PUBLISHER_EVENT_BUS_ARN = aws_cloudwatch_event_bus.main.arn
- MAXIMUM_RUNTIME_MILLISECONDS = "240000" # 4 minutes (Lambda has 5 min timeout)
+ MAXIMUM_RUNTIME_MILLISECONDS = "240000" # 4 minutes (Lambda has 5 min timeout)
POLLING_METRIC_NAME = "mesh-poll-successful-polls"
POLLING_METRIC_NAMESPACE = "dl-mesh-poll"
SSM_PREFIX = "${local.ssm_mesh_prefix}"
diff --git a/infrastructure/terraform/components/dl/module_lambda_move_scanned_files.tf b/infrastructure/terraform/components/dl/module_lambda_move_scanned_files.tf
new file mode 100644
index 00000000..25d08910
--- /dev/null
+++ b/infrastructure/terraform/components/dl/module_lambda_move_scanned_files.tf
@@ -0,0 +1,150 @@
+module "move_scanned_files" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip"
+
+ function_name = "move-scanned-files"
+ description = "A function that handles GuardDuty Malware Protection Object Scan Result and depending on the result moves objects from the unscanned bucket to the file safe or quarantined bucket. "
+
+ 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.move_scanned_files.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 = "move-scanned-files-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 = {
+ "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn
+ "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url
+ "ENVIRONMENT" = var.environment
+ "KEY_PREFIX_UNSCANNED_FILES" = local.csi
+ "UNSCANNED_FILE_S3_BUCKET_NAME" = local.unscanned_files_bucket
+ "SAFE_FILE_S3_BUCKET_NAME" = module.s3bucket_file_safe.bucket
+ "QUARANTINE_FILE_S3_BUCKET_NAME" = module.s3bucket_file_quarantine.bucket
+ }
+}
+
+data "aws_iam_policy_document" "move_scanned_files" {
+ statement {
+ sid = "KMSPermissions"
+ effect = "Allow"
+
+ actions = [
+ "kms:Encrypt",
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ ]
+
+ resources = [
+ module.kms.key_arn,
+ ]
+ }
+
+ statement {
+ sid = "SQSPermissionsFileScannerMoveScannedFiles"
+ effect = "Allow"
+
+ actions = [
+ "sqs:ReceiveMessage",
+ "sqs:DeleteMessage",
+ "sqs:GetQueueAttributes",
+ "sqs:GetQueueUrl",
+ ]
+
+ resources = [
+ module.sqs_move_scanned_files.sqs_queue_arn,
+ ]
+ }
+
+ statement {
+ sid = "PutEvents"
+ effect = "Allow"
+
+ actions = [
+ "events:PutEvents",
+ ]
+
+ resources = [
+ aws_cloudwatch_event_bus.main.arn,
+ ]
+ }
+
+ statement {
+ sid = "SQSPermissionsDLQ"
+ effect = "Allow"
+
+ actions = [
+ "sqs:SendMessage",
+ "sqs:SendMessageBatch",
+ ]
+
+ resources = [
+ module.sqs_event_publisher_errors.sqs_queue_arn,
+ ]
+ }
+
+ statement {
+ sid = "PermissionsToUnscannedBucket"
+ effect = "Allow"
+
+ actions = [
+ "s3:GetObject",
+ "s3:GetObjectTagging",
+ "s3:DeleteObject",
+ ]
+
+ resources = [
+ "arn:aws:s3:::${local.unscanned_files_bucket}/*",
+ ]
+ }
+
+ statement {
+ sid = "PermissionsToSafeFileBucket"
+ effect = "Allow"
+
+ actions = [
+ "s3:PutObject",
+ "s3:PutObjectTagging",
+ ]
+
+ resources = [
+ "${module.s3bucket_file_safe.arn}/*"
+ ]
+ }
+
+ statement {
+ sid = "PermissionsToQuarantineFileBucket"
+ effect = "Allow"
+
+ actions = [
+ "s3:PutObject",
+ "s3:PutObjectTagging",
+ ]
+
+ resources = [
+ "${module.s3bucket_file_quarantine.arn}/*"
+ ]
+ }
+
+}
diff --git a/infrastructure/terraform/components/dl/module_lambda_print_analyser.tf b/infrastructure/terraform/components/dl/module_lambda_print_analyser.tf
index fde104c0..a0dd3b6e 100644
--- a/infrastructure/terraform/components/dl/module_lambda_print_analyser.tf
+++ b/infrastructure/terraform/components/dl/module_lambda_print_analyser.tf
@@ -35,8 +35,8 @@ module "print_analyser" {
log_subscription_role_arn = local.acct.log_subscription_role_arn
lambda_env_vars = {
- "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn
- "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url
+ "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn
+ "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url
}
}
diff --git a/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf b/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf
index 498b7285..fb1e54e7 100644
--- a/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf
+++ b/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf
@@ -35,8 +35,8 @@ module "print_status_handler" {
log_subscription_role_arn = local.acct.log_subscription_role_arn
lambda_env_vars = {
- "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn
- "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url
+ "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn
+ "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url
}
}
diff --git a/infrastructure/terraform/components/dl/module_s3_bucket_cf_logs.tf b/infrastructure/terraform/components/dl/module_s3_bucket_cf_logs.tf
index 8b87de19..a1833846 100644
--- a/infrastructure/terraform/components/dl/module_s3_bucket_cf_logs.tf
+++ b/infrastructure/terraform/components/dl/module_s3_bucket_cf_logs.tf
@@ -1,5 +1,5 @@
module "s3bucket_cf_logs" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip"
providers = {
aws = aws.us-east-1
}
diff --git a/infrastructure/terraform/components/dl/module_s3_bucket_static_assets.tf b/infrastructure/terraform/components/dl/module_s3_bucket_static_assets.tf
index 81dec4a7..19c3d58d 100644
--- a/infrastructure/terraform/components/dl/module_s3_bucket_static_assets.tf
+++ b/infrastructure/terraform/components/dl/module_s3_bucket_static_assets.tf
@@ -1,5 +1,5 @@
module "s3bucket_static_assets" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip"
name = "static-assets"
diff --git a/infrastructure/terraform/components/dl/module_s3bucket_file_quarantine.tf b/infrastructure/terraform/components/dl/module_s3bucket_file_quarantine.tf
new file mode 100644
index 00000000..23461158
--- /dev/null
+++ b/infrastructure/terraform/components/dl/module_s3bucket_file_quarantine.tf
@@ -0,0 +1,85 @@
+module "s3bucket_file_quarantine" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip"
+
+ name = "file-quarantine"
+
+ aws_account_id = var.aws_account_id
+ region = var.region
+ project = var.project
+ environment = var.environment
+ component = local.component
+
+ kms_key_arn = module.kms.key_arn
+
+ policy_documents = [data.aws_iam_policy_document.s3bucket_file_quarantine.json]
+
+ force_destroy = var.force_destroy
+
+ lifecycle_rules = [
+ {
+ enabled = true
+
+ expiration = {
+ days = "90"
+ }
+
+ noncurrent_version_transition = [
+ {
+ noncurrent_days = "30"
+ storage_class = "STANDARD_IA"
+ }
+ ]
+
+ noncurrent_version_expiration = {
+ noncurrent_days = "90"
+ }
+
+ abort_incomplete_multipart_upload = {
+ days = "1"
+ }
+ }
+ ]
+}
+
+data "aws_iam_policy_document" "s3bucket_file_quarantine" {
+ statement {
+ sid = "AllowManagedAccountsToList"
+ effect = "Allow"
+
+ actions = [
+ "s3:ListBucket",
+ ]
+
+ resources = [
+ module.s3bucket_file_quarantine.arn,
+ ]
+
+ principals {
+ type = "AWS"
+ identifiers = [
+ "arn:aws:iam::${var.aws_account_id}:root"
+ ]
+ }
+ }
+
+ statement {
+ sid = "AllowManagedAccountsToGetPut"
+ effect = "Allow"
+
+ actions = [
+ "s3:GetObject",
+ "s3:PutObject",
+ ]
+
+ resources = [
+ "${module.s3bucket_file_quarantine.arn}/*",
+ ]
+
+ principals {
+ type = "AWS"
+ identifiers = [
+ "arn:aws:iam::${var.aws_account_id}:root"
+ ]
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/dl/module_s3bucket_file_safe.tf b/infrastructure/terraform/components/dl/module_s3bucket_file_safe.tf
index 4fabd0cc..41888079 100644
--- a/infrastructure/terraform/components/dl/module_s3bucket_file_safe.tf
+++ b/infrastructure/terraform/components/dl/module_s3bucket_file_safe.tf
@@ -1,5 +1,5 @@
module "s3bucket_file_safe" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip"
name = "file-safe"
diff --git a/infrastructure/terraform/components/dl/module_s3bucket_letters.tf b/infrastructure/terraform/components/dl/module_s3bucket_letters.tf
index 63eefd2e..42d5ef87 100644
--- a/infrastructure/terraform/components/dl/module_s3bucket_letters.tf
+++ b/infrastructure/terraform/components/dl/module_s3bucket_letters.tf
@@ -1,5 +1,5 @@
module "s3bucket_letters" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip"
name = "letters"
diff --git a/infrastructure/terraform/components/dl/module_s3bucket_non_pii_data.tf b/infrastructure/terraform/components/dl/module_s3bucket_non_pii_data.tf
index b7fb6284..5501fea5 100644
--- a/infrastructure/terraform/components/dl/module_s3bucket_non_pii_data.tf
+++ b/infrastructure/terraform/components/dl/module_s3bucket_non_pii_data.tf
@@ -1,5 +1,5 @@
module "s3bucket_non_pii_data" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip"
name = "non-pii-data"
diff --git a/infrastructure/terraform/components/dl/module_s3bucket_pii_data.tf b/infrastructure/terraform/components/dl/module_s3bucket_pii_data.tf
index 4a80ec95..fe6648ba 100644
--- a/infrastructure/terraform/components/dl/module_s3bucket_pii_data.tf
+++ b/infrastructure/terraform/components/dl/module_s3bucket_pii_data.tf
@@ -1,5 +1,5 @@
module "s3bucket_pii_data" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip"
name = "pii-data"
diff --git a/infrastructure/terraform/components/dl/module_s3bucket_reporting.tf b/infrastructure/terraform/components/dl/module_s3bucket_reporting.tf
index 739f7c99..e9e84559 100644
--- a/infrastructure/terraform/components/dl/module_s3bucket_reporting.tf
+++ b/infrastructure/terraform/components/dl/module_s3bucket_reporting.tf
@@ -1,5 +1,5 @@
module "s3bucket_reporting" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip"
name = "reporting"
diff --git a/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf b/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf
index 6aab4701..7723aaaf 100644
--- a/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf
+++ b/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf
@@ -1,5 +1,5 @@
module "sqs_core_notifier" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip"
aws_account_id = var.aws_account_id
component = local.component
@@ -38,7 +38,7 @@ data "aws_iam_policy_document" "sqs_inbound_event" {
condition {
test = "ArnLike"
variable = "aws:SourceArn"
- values = [ aws_cloudwatch_event_rule.pdm_resource_available.arn ]
+ values = [aws_cloudwatch_event_rule.pdm_resource_available.arn]
}
}
}
diff --git a/infrastructure/terraform/components/dl/module_sqs_event_publisher_errors.tf b/infrastructure/terraform/components/dl/module_sqs_event_publisher_errors.tf
index fbe89c2e..952cac58 100644
--- a/infrastructure/terraform/components/dl/module_sqs_event_publisher_errors.tf
+++ b/infrastructure/terraform/components/dl/module_sqs_event_publisher_errors.tf
@@ -1,5 +1,5 @@
module "sqs_event_publisher_errors" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip"
aws_account_id = var.aws_account_id
component = local.component
diff --git a/infrastructure/terraform/components/dl/module_sqs_mesh_download.tf b/infrastructure/terraform/components/dl/module_sqs_mesh_download.tf
index f33e7881..4c8ae79e 100644
--- a/infrastructure/terraform/components/dl/module_sqs_mesh_download.tf
+++ b/infrastructure/terraform/components/dl/module_sqs_mesh_download.tf
@@ -1,5 +1,5 @@
module "sqs_mesh_download" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip"
aws_account_id = var.aws_account_id
component = local.component
diff --git a/infrastructure/terraform/components/dl/module_sqs_move_scanned_files.tf b/infrastructure/terraform/components/dl/module_sqs_move_scanned_files.tf
new file mode 100644
index 00000000..cb444d5e
--- /dev/null
+++ b/infrastructure/terraform/components/dl/module_sqs_move_scanned_files.tf
@@ -0,0 +1,44 @@
+module "sqs_move_scanned_files" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip"
+
+ aws_account_id = var.aws_account_id
+ component = local.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+ name = "move-scanned-files"
+
+ sqs_kms_key_arn = module.kms.key_arn
+
+ visibility_timeout_seconds = 60
+
+ create_dlq = true
+
+ sqs_policy_overload = data.aws_iam_policy_document.sqs_move_scanned_files.json
+}
+
+data "aws_iam_policy_document" "sqs_move_scanned_files" {
+ 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}-move-scanned-files-queue"
+ ]
+
+ condition {
+ test = "ArnLike"
+ variable = "aws:SourceArn"
+ values = [aws_cloudwatch_event_rule.guardduty_scan_result.arn]
+ }
+ }
+}
diff --git a/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf b/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf
index f7cb3842..2f3b22d9 100644
--- a/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf
+++ b/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf
@@ -1,5 +1,5 @@
module "sqs_pdm_poll" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip"
aws_account_id = var.aws_account_id
component = local.component
diff --git a/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf b/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf
index cb45762a..a9936526 100644
--- a/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf
+++ b/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf
@@ -1,5 +1,5 @@
module "sqs_pdm_uploader" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip"
aws_account_id = var.aws_account_id
component = local.component
diff --git a/infrastructure/terraform/components/dl/module_sqs_scanner.tf b/infrastructure/terraform/components/dl/module_sqs_scanner.tf
index 248fe16a..cabecbc6 100644
--- a/infrastructure/terraform/components/dl/module_sqs_scanner.tf
+++ b/infrastructure/terraform/components/dl/module_sqs_scanner.tf
@@ -1,5 +1,5 @@
module "sqs_scanner" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip"
aws_account_id = var.aws_account_id
component = local.component
diff --git a/infrastructure/terraform/components/dl/module_sqs_ttl.tf b/infrastructure/terraform/components/dl/module_sqs_ttl.tf
index 38638a2b..20b0e7ee 100644
--- a/infrastructure/terraform/components/dl/module_sqs_ttl.tf
+++ b/infrastructure/terraform/components/dl/module_sqs_ttl.tf
@@ -1,5 +1,5 @@
module "sqs_ttl" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip"
aws_account_id = var.aws_account_id
component = local.component
diff --git a/infrastructure/terraform/components/dl/module_sqs_ttl_handle_expiry_errors.tf b/infrastructure/terraform/components/dl/module_sqs_ttl_handle_expiry_errors.tf
index 5093334c..1aa34864 100644
--- a/infrastructure/terraform/components/dl/module_sqs_ttl_handle_expiry_errors.tf
+++ b/infrastructure/terraform/components/dl/module_sqs_ttl_handle_expiry_errors.tf
@@ -1,5 +1,5 @@
module "sqs_ttl_handle_expiry_errors" {
- source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip"
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip"
aws_account_id = var.aws_account_id
component = local.component
diff --git a/infrastructure/terraform/components/dl/ssm_parameter_mesh_config.tf b/infrastructure/terraform/components/dl/ssm_parameter_mesh_config.tf
index 35045c18..398c41dd 100644
--- a/infrastructure/terraform/components/dl/ssm_parameter_mesh_config.tf
+++ b/infrastructure/terraform/components/dl/ssm_parameter_mesh_config.tf
@@ -10,7 +10,7 @@ resource "aws_ssm_parameter" "mesh_config" {
mesh_mailbox = "mock-mailbox"
mesh_mailbox_password = "mock-password"
mesh_shared_key = "mock-shared-key"
- }) : jsonencode({
+ }) : jsonencode({
mesh_endpoint = "UNSET"
mesh_mailbox = "UNSET"
mesh_mailbox_password = "UNSET"
@@ -18,7 +18,7 @@ resource "aws_ssm_parameter" "mesh_config" {
})
tags = merge(local.default_tags, {
- Backup = "true"
+ Backup = "true"
Description = "MESH configuration"
})
@@ -37,7 +37,7 @@ resource "aws_ssm_parameter" "mesh_client_cert" {
value = var.enable_mock_mesh ? "mock-cert" : "UNSET"
tags = merge(local.default_tags, {
- Backup = "true"
+ Backup = "true"
Description = "MESH client certificate"
})
@@ -56,7 +56,7 @@ resource "aws_ssm_parameter" "mesh_client_key" {
value = var.enable_mock_mesh ? "mock-key" : "UNSET"
tags = merge(local.default_tags, {
- Backup = "true"
+ Backup = "true"
Description = "MESH client private key"
})
diff --git a/infrastructure/terraform/components/dl/variables.tf b/infrastructure/terraform/components/dl/variables.tf
index fb9b496a..3a81e659 100644
--- a/infrastructure/terraform/components/dl/variables.tf
+++ b/infrastructure/terraform/components/dl/variables.tf
@@ -186,3 +186,9 @@ variable "pii_data_retention_policy_days" {
description = "The number of days for data retention policy for PII"
default = 534
}
+
+variable "default_cloudwatch_event_bus_name" {
+ type = string
+ description = "The name of the default cloudwatch event bus. This is needed as GuardDuty Scan Result events are sent to the default bus"
+ default = "default"
+}
diff --git a/lambdas/move-scanned-files-lambda/jest.config.ts b/lambdas/move-scanned-files-lambda/jest.config.ts
new file mode 100644
index 00000000..c02601ae
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/jest.config.ts
@@ -0,0 +1,5 @@
+import { baseJestConfig } from '../../jest.config.base';
+
+const config = baseJestConfig;
+
+export default config;
diff --git a/lambdas/move-scanned-files-lambda/package.json b/lambdas/move-scanned-files-lambda/package.json
new file mode 100644
index 00000000..923a6a41
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/package.json
@@ -0,0 +1,26 @@
+{
+ "dependencies": {
+ "aws-lambda": "^1.0.7",
+ "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",
+ "jest-mock-extended": "^3.0.7",
+ "typescript": "^5.9.3"
+ },
+ "name": "nhs-notify-digital-move-scanned-files-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/move-scanned-files-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/apis/sqs-handler.test.ts
new file mode 100644
index 00000000..d56371eb
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/__tests__/apis/sqs-handler.test.ts
@@ -0,0 +1,241 @@
+import type { SQSEvent, SQSRecord } from 'aws-lambda';
+import { mock } from 'jest-mock-extended';
+import { SqsHandlerDependencies, createHandler } from 'apis/sqs-handler';
+import { EventPublisher, Logger } from 'utils';
+import { MoveFileHandler } from 'app/move-file-handler';
+import { parseSqsRecord } from 'app/parse-sqs-message';
+import { FileQuarantined, FileSafe } from 'digital-letters-events';
+import {
+ guardDutyNoThreadsFoundEvent,
+ guardDutyThreadsFoundEvent,
+} from '__tests__/constants';
+
+jest.mock('app/parse-sqs-message');
+
+const createSqsEvent = (recordCount: number): SQSEvent => ({
+ Records: Array.from(
+ { length: recordCount },
+ (_, i): SQSRecord => ({
+ messageId: `message-id-${i + 1}`,
+ receiptHandle: `receipt-handle-${i + 1}`,
+ body: JSON.stringify({
+ detail: guardDutyNoThreadsFoundEvent,
+ }),
+ attributes: {
+ ApproximateReceiveCount: '1',
+ SentTimestamp: '1234567890',
+ SenderId: 'sender-id',
+ ApproximateFirstReceiveTimestamp: '1234567890',
+ },
+ messageAttributes: {},
+ md5OfBody: 'md5',
+ eventSource: 'aws:sqs',
+ eventSourceARN: 'arn:aws:sqs:region:account:queue',
+ awsRegion: 'eu-west-2',
+ }),
+ ),
+});
+
+describe('sqs-handler', () => {
+ const mockLogger = mock();
+ const mockEventPublisher = mock();
+ const mockMoveFileHandler = mock();
+ const mockParseSqsRecord = jest.mocked(parseSqsRecord);
+
+ const dependencies: SqsHandlerDependencies = {
+ logger: mockLogger,
+ eventPublisher: mockEventPublisher,
+ moveFileHandler: mockMoveFileHandler,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('createHandler', () => {
+ it('creates a handler function', () => {
+ const handler = createHandler(dependencies);
+ expect(typeof handler).toBe('function');
+ });
+
+ it('processes a single SQS record successfully', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const handler = createHandler(dependencies);
+
+ mockParseSqsRecord.mockReturnValue(guardDutyNoThreadsFoundEvent.detail);
+ mockMoveFileHandler.handle.mockResolvedValue({
+ fileSafe: {
+ specversion: '1.0',
+ id: 'test-id',
+ source: '/test',
+ type: 'uk.nhs.notify.digital.letters.print.file.safe.v1',
+ time: '2024-01-01T00:00:00Z',
+ data: {
+ messageReference: 'msg-ref',
+ senderId: 'sender-id',
+ letterUri: 'https://bucket/key',
+ createdAt: '2024-01-01T00:00:00Z',
+ },
+ subject: 'test-subject',
+ traceparent: 'test-traceparent',
+ recordedtime: '2024-01-01T00:00:00Z',
+ severitynumber: 2,
+ },
+ });
+
+ const result = await handler(sqsEvent);
+
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: 'Received SQS Event of 1 record(s)',
+ });
+ expect(mockParseSqsRecord).toHaveBeenCalledWith(
+ sqsEvent.Records[0],
+ mockLogger,
+ );
+ expect(mockMoveFileHandler.handle).toHaveBeenCalledWith(
+ guardDutyNoThreadsFoundEvent.detail,
+ );
+ expect(mockEventPublisher.sendEvents).toHaveBeenCalledTimes(1);
+ expect(result).toEqual({ batchItemFailures: [] });
+ });
+
+ it('publishes both FileSafe and FileQuarantined events from multiple records', async () => {
+ const sqsEvent = createSqsEvent(2);
+ const handler = createHandler(dependencies);
+
+ const mockFileSafeEvent: FileSafe = {
+ specversion: '1.0',
+ id: 'test-id-1',
+ source: '/test',
+ type: 'uk.nhs.notify.digital.letters.print.file.safe.v1',
+ time: '2024-01-01T00:00:00Z',
+ data: {
+ messageReference: 'msg-ref-1',
+ senderId: 'sender-id-1',
+ letterUri: 'https://bucket/key1',
+ createdAt: '2024-01-01T00:00:00Z',
+ },
+ subject: 'test-subject',
+ traceparent: 'test-traceparent',
+ recordedtime: '2024-01-01T00:00:00Z',
+ severitynumber: 2,
+ };
+
+ const mockFileQuarantinedEvent: FileQuarantined = {
+ specversion: '1.0',
+ id: 'test-id-2',
+ source: '/test',
+ type: 'uk.nhs.notify.digital.letters.print.file.quarantined.v1',
+ time: '2024-01-01T00:00:00Z',
+ data: {
+ messageReference: 'msg-ref-2',
+ senderId: 'sender-id-2',
+ letterUri: 'https://bucket/key2',
+ createdAt: '2024-01-01T00:00:00Z',
+ },
+ subject: 'test-subject',
+ traceparent: 'test-traceparent',
+ recordedtime: '2024-01-01T00:00:00Z',
+ severitynumber: 2,
+ };
+
+ mockParseSqsRecord
+ .mockReturnValueOnce(guardDutyNoThreadsFoundEvent.detail)
+ .mockReturnValueOnce(guardDutyThreadsFoundEvent.detail);
+ mockMoveFileHandler.handle
+ .mockResolvedValueOnce({ fileSafe: mockFileSafeEvent })
+ .mockResolvedValueOnce({
+ fileQuarantined: mockFileQuarantinedEvent,
+ });
+
+ await handler(sqsEvent);
+
+ expect(mockEventPublisher.sendEvents).toHaveBeenCalledTimes(2);
+ expect(mockEventPublisher.sendEvents).toHaveBeenNthCalledWith(
+ 1,
+ [mockFileSafeEvent],
+ expect.any(Function),
+ );
+ expect(mockEventPublisher.sendEvents).toHaveBeenNthCalledWith(
+ 2,
+ [mockFileQuarantinedEvent],
+ expect.any(Function),
+ );
+ });
+
+ it('does not publish events when handler returns null', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const handler = createHandler(dependencies);
+
+ mockParseSqsRecord.mockReturnValue(guardDutyNoThreadsFoundEvent.detail);
+ mockMoveFileHandler.handle.mockResolvedValue(null);
+
+ await handler(sqsEvent);
+
+ expect(mockEventPublisher.sendEvents).not.toHaveBeenCalled();
+ });
+
+ it('returns batch item failures when processing fails', async () => {
+ const sqsEvent = createSqsEvent(2);
+ const handler = createHandler(dependencies);
+
+ mockParseSqsRecord
+ .mockReturnValueOnce(guardDutyNoThreadsFoundEvent.detail)
+ .mockImplementationOnce(() => {
+ throw new Error('Parse error');
+ });
+ mockMoveFileHandler.handle.mockResolvedValue({
+ fileSafe: {
+ specversion: '1.0',
+ id: 'test-id',
+ source: '/test',
+ type: 'uk.nhs.notify.digital.letters.print.file.safe.v1',
+ time: '2024-01-01T00:00:00Z',
+ data: {
+ messageReference: 'msg-ref',
+ senderId: 'sender-id',
+ letterUri: 'https://bucket/key',
+ createdAt: '2024-01-01T00:00:00Z',
+ },
+ subject: 'test-subject',
+ traceparent: 'test-traceparent',
+ recordedtime: '2024-01-01T00:00:00Z',
+ severitynumber: 2,
+ },
+ });
+
+ const result = await handler(sqsEvent);
+
+ expect(mockLogger.warn).toHaveBeenCalledWith({
+ error: 'Parse error',
+ description: 'Failed processing message',
+ messageId: 'message-id-2',
+ });
+ expect(result.batchItemFailures).toEqual([
+ { itemIdentifier: 'message-id-2' },
+ ]);
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: '1 of 2 records processed successfully',
+ });
+ });
+
+ it('handles errors from moveFileHandler', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const handler = createHandler(dependencies);
+
+ mockParseSqsRecord.mockReturnValue(guardDutyNoThreadsFoundEvent.detail);
+ mockMoveFileHandler.handle.mockRejectedValue(new Error('Handler error'));
+
+ const result = await handler(sqsEvent);
+
+ expect(mockLogger.warn).toHaveBeenCalledWith({
+ error: 'Handler error',
+ description: 'Failed processing message',
+ messageId: 'message-id-1',
+ });
+ expect(result.batchItemFailures).toEqual([
+ { itemIdentifier: 'message-id-1' },
+ ]);
+ });
+ });
+});
diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/app/move-file-handler.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/app/move-file-handler.test.ts
new file mode 100644
index 00000000..27d54fc0
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/__tests__/app/move-file-handler.test.ts
@@ -0,0 +1,435 @@
+import { mock } from 'jest-mock-extended';
+import { Logger } from 'utils/logger';
+import { MoveFileHandler } from 'app/move-file-handler';
+import { MoveScannedFilesConfig } from 'infra/config';
+import * as utils from 'utils';
+import {
+ guardDutyNoThreadsFoundEvent,
+ guardDutyThreadsFoundEvent,
+} from '__tests__/constants';
+
+jest.mock('utils', () => ({
+ ...jest.requireActual('utils'),
+ copyAndDeleteObjectS3: jest.fn(),
+ getS3ObjectMetadata: jest.fn(),
+}));
+
+jest.mock('domain/mapper', () => ({
+ createFileSafeEvent: jest.fn(
+ (messageRef, senderId, letterUri, createdAt) => ({
+ specversion: '1.0',
+ id: 'test-id',
+ source: '/test',
+ type: 'uk.nhs.notify.digital.letters.print.file.safe.v1',
+ time: '2024-01-01T00:00:00Z',
+ data: {
+ messageReference: messageRef,
+ senderId,
+ letterUri,
+ createdAt,
+ },
+ subject: 'test-subject',
+ traceparent: 'test-traceparent',
+ recordedtime: '2024-01-01T00:00:00Z',
+ severitynumber: 2,
+ }),
+ ),
+ createFileQuarantinedEvent: jest.fn(
+ (messageRef, senderId, letterUri, createdAt) => ({
+ specversion: '1.0',
+ id: 'test-id',
+ source: '/test',
+ type: 'uk.nhs.notify.digital.letters.print.file.quarantined.v1',
+ time: '2024-01-01T00:00:00Z',
+ data: {
+ messageReference: messageRef,
+ senderId,
+ letterUri,
+ createdAt,
+ },
+ subject: 'test-subject',
+ traceparent: 'test-traceparent',
+ recordedtime: '2024-01-01T00:00:00Z',
+ severitynumber: 2,
+ }),
+ ),
+}));
+
+describe('MoveFileHandler', () => {
+ const mockLogger = mock();
+ const mockConfig: MoveScannedFilesConfig = {
+ eventPublisherEventBusArn: 'arn:aws:events:test',
+ eventPublisherDlqUrl: 'https://sqs.test',
+ environment: 'test',
+ keyPrefixUnscannedFiles: 'dl/',
+ unscannedFileS3BucketName: 'unscanned-bucket',
+ safeFileS3BucketName: 'safe-bucket',
+ quarantineFileS3BucketName: 'quarantine-bucket',
+ };
+
+ const mockCopyAndDeleteObjectS3 = jest.mocked(utils.copyAndDeleteObjectS3);
+ const mockGetS3ObjectMetadata = jest.mocked(utils.getS3ObjectMetadata);
+
+ let handler: MoveFileHandler;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ handler = new MoveFileHandler(mockLogger, mockConfig);
+ mockCopyAndDeleteObjectS3.mockResolvedValue();
+ mockGetS3ObjectMetadata.mockResolvedValue({
+ messagereference: 'msg-ref-123',
+ senderid: 'sender-456',
+ createdat: '2024-01-01T00:00:00Z',
+ });
+ });
+
+ describe('isEventForDigitalLetters', () => {
+ it('returns true when event is for digital letters bucket and prefix', () => {
+ const eventDetail = {
+ ...guardDutyNoThreadsFoundEvent.detail,
+ s3ObjectDetails: {
+ ...guardDutyNoThreadsFoundEvent.detail.s3ObjectDetails,
+ bucketName: 'unscanned-bucket',
+ objectKey: 'dl/test-file.pdf',
+ },
+ };
+
+ const result = handler.isEventForDigitalLetters(eventDetail);
+
+ expect(result).toBe(true);
+ expect(mockLogger.warn).not.toHaveBeenCalled();
+ });
+
+ it('returns false when bucket name does not match', () => {
+ const eventDetail = {
+ ...guardDutyNoThreadsFoundEvent.detail,
+ s3ObjectDetails: {
+ ...guardDutyNoThreadsFoundEvent.detail.s3ObjectDetails,
+ bucketName: 'wrong-bucket',
+ objectKey: 'dl/test-file.pdf',
+ },
+ };
+
+ const result = handler.isEventForDigitalLetters(eventDetail);
+
+ expect(result).toBe(false);
+ expect(mockLogger.warn).toHaveBeenCalledWith({
+ description:
+ 'Received scan result for file not in unscanned bucket, ignoring event.',
+ expectedUnscannedBucketName: 'unscanned-bucket',
+ expectedKeyPrefix: 'dl/',
+ receivedBucketName: 'wrong-bucket',
+ receivedObjectKey: 'dl/test-file.pdf',
+ });
+ });
+
+ it('returns false when object key does not start with prefix', () => {
+ const eventDetail = {
+ ...guardDutyNoThreadsFoundEvent.detail,
+ s3ObjectDetails: {
+ ...guardDutyNoThreadsFoundEvent.detail.s3ObjectDetails,
+ bucketName: 'unscanned-bucket',
+ objectKey: 'other/test-file.pdf',
+ },
+ };
+
+ const result = handler.isEventForDigitalLetters(eventDetail);
+
+ expect(result).toBe(false);
+ expect(mockLogger.warn).toHaveBeenCalledWith({
+ description:
+ 'Received scan result for file not in unscanned bucket, ignoring event.',
+ expectedUnscannedBucketName: 'unscanned-bucket',
+ expectedKeyPrefix: 'dl/',
+ receivedBucketName: 'unscanned-bucket',
+ receivedObjectKey: 'other/test-file.pdf',
+ });
+ });
+
+ it('returns false when both bucket and prefix are wrong', () => {
+ const eventDetail = {
+ ...guardDutyNoThreadsFoundEvent.detail,
+ s3ObjectDetails: {
+ ...guardDutyNoThreadsFoundEvent.detail.s3ObjectDetails,
+ bucketName: 'wrong-bucket',
+ objectKey: 'wrong-prefix/test-file.pdf',
+ },
+ };
+
+ const result = handler.isEventForDigitalLetters(eventDetail);
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('extractMetadata', () => {
+ it('extracts metadata successfully when all required fields are present', async () => {
+ const objectDetails = {
+ bucketName: 'test-bucket',
+ objectKey: 'test-key',
+ };
+
+ mockGetS3ObjectMetadata.mockResolvedValue({
+ messagereference: 'msg-ref-123',
+ senderid: 'sender-456',
+ createdat: '2024-06-01T12:00:00Z',
+ });
+
+ const result = await handler.extractMetadata(objectDetails);
+
+ expect(mockGetS3ObjectMetadata).toHaveBeenCalledWith({
+ Bucket: 'test-bucket',
+ Key: 'test-key',
+ });
+ expect(result).toEqual({
+ senderId: 'sender-456',
+ messageReference: 'msg-ref-123',
+ createdAt: '2024-06-01T12:00:00Z',
+ });
+ });
+
+ it('returns null when message-reference is missing', async () => {
+ const objectDetails = {
+ bucketName: 'test-bucket',
+ objectKey: 'test-key',
+ };
+
+ mockGetS3ObjectMetadata.mockResolvedValue({
+ 'sender-id': 'sender-456',
+ 'created-at': '2024-06-01T12:00:00Z',
+ });
+
+ const result = await handler.extractMetadata(objectDetails);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null when sender-id is missing', async () => {
+ const objectDetails = {
+ bucketName: 'test-bucket',
+ objectKey: 'test-key',
+ };
+
+ mockGetS3ObjectMetadata.mockResolvedValue({
+ 'message-reference': 'msg-ref-123',
+ 'created-at': '2024-06-01T12:00:00Z',
+ });
+
+ const result = await handler.extractMetadata(objectDetails);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null when created-at is missing', async () => {
+ const objectDetails = {
+ bucketName: 'test-bucket',
+ objectKey: 'test-key',
+ };
+
+ mockGetS3ObjectMetadata.mockResolvedValue({
+ 'message-reference': 'msg-ref-123',
+ 'sender-id': 'sender-456',
+ });
+
+ const result = await handler.extractMetadata(objectDetails);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns null when metadata is undefined', async () => {
+ const objectDetails = {
+ bucketName: 'test-bucket',
+ objectKey: 'test-key',
+ };
+
+ // need to test the condition where no metadata is returned. leaving just undefined produces a lint error, removing undefined then causes typecheck error.
+ mockGetS3ObjectMetadata.mockResolvedValue(undefined as never);
+
+ const result = await handler.extractMetadata(objectDetails);
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('createObjectsFromData', () => {
+ const metadata = {
+ senderId: 'sender-123',
+ messageReference: 'msg-ref-456',
+ createdAt: '2024-06-01T12:00:00Z',
+ };
+
+ it('creates FileSafe event when scan completed with no threats', () => {
+ const result = handler.createObjectsFromData(
+ 'COMPLETED',
+ 'dl/test-file.pdf',
+ guardDutyNoThreadsFoundEvent.detail,
+ metadata,
+ );
+
+ expect(result.source).toEqual({
+ Bucket: 'unscanned-bucket',
+ Key: 'dl/test-file.pdf',
+ });
+ expect(result.destination).toEqual({
+ Bucket: 'safe-bucket',
+ Key: 'dl/test-file.pdf',
+ });
+ expect(result.eventToPublish.fileSafe).toBeDefined();
+ expect(result.eventToPublish.fileSafe!.data.messageReference).toBe(
+ 'msg-ref-456',
+ );
+ expect(result.eventToPublish.fileSafe!.data.senderId).toBe('sender-123');
+ expect(result.eventToPublish.fileSafe!.data.letterUri).toBe(
+ 's3://safe-bucket/dl/test-file.pdf',
+ );
+ expect(result.eventToPublish.fileQuarantined).toBeUndefined();
+ });
+
+ it('creates FileQuarantined event when threats are found', () => {
+ const scanDetail = {
+ ...guardDutyThreadsFoundEvent.detail,
+ scanResultDetails: {
+ scanResultStatus: 'THREATS_FOUND' as const,
+ threats: [{ name: 'EICAR-Test-File' }],
+ },
+ };
+
+ const result = handler.createObjectsFromData(
+ 'COMPLETED',
+ 'dl/infected-file.pdf',
+ scanDetail,
+ metadata,
+ );
+
+ expect(result.source).toEqual({
+ Bucket: 'unscanned-bucket',
+ Key: 'dl/infected-file.pdf',
+ });
+ expect(result.destination).toEqual({
+ Bucket: 'quarantine-bucket',
+ Key: 'dl/infected-file.pdf',
+ });
+ expect(result.eventToPublish.fileQuarantined).toBeDefined();
+ expect(result.eventToPublish.fileQuarantined?.data.messageReference).toBe(
+ 'msg-ref-456',
+ );
+ expect(result.eventToPublish.fileQuarantined?.data.senderId).toBe(
+ 'sender-123',
+ );
+ expect(result.eventToPublish.fileQuarantined?.data.letterUri).toBe(
+ 's3://quarantine-bucket/dl/infected-file.pdf',
+ );
+ expect(result.eventToPublish.fileSafe).toBeUndefined();
+ expect(mockLogger.warn).toHaveBeenCalledWith({
+ description: 'File scan did not complete successfully',
+ scanStatus: 'COMPLETED',
+ scanResultDetails: scanDetail.scanResultDetails,
+ key: 'd-file.pdf',
+ messageReference: 'msg-ref-456',
+ senderId: 'sender-123',
+ });
+ });
+
+ it('creates FileQuarantined event when scan status is not COMPLETED', () => {
+ const result = handler.createObjectsFromData(
+ 'FAILED',
+ 'dl/failed-scan.pdf',
+ guardDutyNoThreadsFoundEvent.detail,
+ metadata,
+ );
+
+ expect(result.destination.Bucket).toBe('quarantine-bucket');
+ expect(result.eventToPublish.fileQuarantined).toBeDefined();
+ expect(result.eventToPublish.fileSafe).toBeUndefined();
+ expect(mockLogger.warn).toHaveBeenCalledWith({
+ description: 'File scan did not complete successfully',
+ scanStatus: 'FAILED',
+ scanResultDetails:
+ guardDutyNoThreadsFoundEvent.detail.scanResultDetails,
+ key: 'd-scan.pdf',
+ messageReference: 'msg-ref-456',
+ senderId: 'sender-123',
+ });
+ });
+ });
+
+ describe('handle', () => {
+ it('processes safe file successfully', async () => {
+ const result = await handler.handle(guardDutyNoThreadsFoundEvent.detail);
+
+ expect(result).not.toBeNull();
+ expect(mockLogger.info).toHaveBeenCalled();
+ expect(mockGetS3ObjectMetadata).toHaveBeenCalledWith({
+ Bucket: 'unscanned-bucket',
+ Key: 'dl/sample.pdf',
+ });
+ expect(mockCopyAndDeleteObjectS3).toHaveBeenCalledWith(
+ { Bucket: 'unscanned-bucket', Key: 'dl/sample.pdf' },
+ { Bucket: 'safe-bucket', Key: 'dl/sample.pdf' },
+ );
+ expect(result?.fileSafe).toBeDefined();
+ expect(result?.fileQuarantined).toBeUndefined();
+ });
+
+ it('processes infected file successfully', async () => {
+ const result = await handler.handle(guardDutyThreadsFoundEvent.detail);
+
+ expect(mockCopyAndDeleteObjectS3).toHaveBeenCalledWith(
+ { Bucket: 'unscanned-bucket', Key: 'dl/sample.pdf' },
+ { Bucket: 'quarantine-bucket', Key: 'dl/sample.pdf' },
+ );
+ expect(result?.fileQuarantined).toBeDefined();
+ expect(result?.fileSafe).toBeUndefined();
+ });
+
+ it('returns null when event is not for digital letters', async () => {
+ const scanResult = {
+ ...guardDutyNoThreadsFoundEvent,
+ detail: {
+ ...guardDutyNoThreadsFoundEvent.detail,
+ s3ObjectDetails: {
+ ...guardDutyNoThreadsFoundEvent.detail.s3ObjectDetails,
+ bucketName: 'wrong-bucket',
+ objectKey: 'dl/file.pdf',
+ },
+ },
+ };
+
+ const result = await handler.handle(scanResult.detail);
+
+ expect(result).toBeNull();
+ expect(mockLogger.warn).toHaveBeenCalledWith({
+ description: 'Scan result event is not valid, skipping processing.',
+ sourceBucket: 'wrong-bucket',
+ key: 'dl/file.pdf',
+ });
+ expect(mockCopyAndDeleteObjectS3).not.toHaveBeenCalled();
+ });
+
+ it('returns null when metadata extraction fails', async () => {
+ const scanResult = {
+ ...guardDutyNoThreadsFoundEvent,
+ detail: {
+ ...guardDutyNoThreadsFoundEvent.detail,
+ s3ObjectDetails: {
+ ...guardDutyNoThreadsFoundEvent.detail.s3ObjectDetails,
+ bucketName: 'unscanned-bucket',
+ objectKey: 'dl/file-without-metadata.pdf',
+ },
+ },
+ };
+
+ mockGetS3ObjectMetadata.mockResolvedValue({});
+
+ const result = await handler.handle(scanResult.detail);
+
+ expect(result).toBeNull();
+ expect(mockLogger.error).toHaveBeenCalledWith({
+ description:
+ 'Failed to extract required metadata from scanned file, skipping processing.',
+ subkey: 'tadata.pdf',
+ });
+ expect(mockCopyAndDeleteObjectS3).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/app/parse-sqs-message.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/app/parse-sqs-message.test.ts
new file mode 100644
index 00000000..6c5fcc2c
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/__tests__/app/parse-sqs-message.test.ts
@@ -0,0 +1,44 @@
+import type { SQSRecord } from 'aws-lambda';
+import { mock } from 'jest-mock-extended';
+import { Logger } from 'utils';
+import { parseSqsRecord } from 'app/parse-sqs-message';
+import { guardDutyNoThreadsFoundEvent } from '__tests__/constants';
+
+describe('parseSqsRecord', () => {
+ const mockLogger = mock();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('parses SQS record body and returns GuardDutyScanResultNotificationEvent.detail', () => {
+ const sqsRecord: SQSRecord = {
+ messageId: 'message-id-1',
+ receiptHandle: 'receipt-handle-1',
+ body: JSON.stringify({ detail: guardDutyNoThreadsFoundEvent.detail }),
+ attributes: {
+ ApproximateReceiveCount: '1',
+ SentTimestamp: '1234567890',
+ SenderId: 'sender-id',
+ ApproximateFirstReceiveTimestamp: '1234567890',
+ },
+ messageAttributes: {},
+ md5OfBody: 'md5',
+ eventSource: 'aws:sqs',
+ eventSourceARN: 'arn:aws:sqs:region:account:queue',
+ awsRegion: 'eu-west-2',
+ };
+
+ const result = parseSqsRecord(sqsRecord, mockLogger);
+
+ expect(mockLogger.info).toHaveBeenCalledWith({
+ description: 'Parsing SQS Record',
+ messageId: 'message-id-1',
+ });
+ expect(mockLogger.debug).toHaveBeenCalledWith({
+ description: 'Returning detail as GuardDutyScanResultNotificationEvent',
+ detail: guardDutyNoThreadsFoundEvent.detail,
+ });
+ expect(result).toEqual(guardDutyNoThreadsFoundEvent.detail);
+ });
+});
diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/constants.ts b/lambdas/move-scanned-files-lambda/src/__tests__/constants.ts
new file mode 100644
index 00000000..7c1a5ff0
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/__tests__/constants.ts
@@ -0,0 +1,51 @@
+import { GuardDutyScanResultNotificationEvent } from 'aws-lambda';
+
+const baseEvent: GuardDutyScanResultNotificationEvent = {
+ id: '72c7d362-737a-6dce-fc78-9e27a0171419',
+ version: '0',
+ source: 'aws.guardduty',
+ account: '111122223333',
+ time: '2024-02-28T01:01:01Z',
+ region: 'us-east-1',
+ resources: [
+ 'arn:aws:guardduty:us-east-1:111122223333:malware-protection-plan/b4c7f464ab3a4EXAMPLE',
+ ],
+ 'detail-type': 'GuardDuty Malware Protection Object Scan Result',
+ detail: {
+ schemaVersion: '1.0',
+ scanStatus: 'COMPLETED',
+ resourceType: 'S3_OBJECT',
+ s3ObjectDetails: {
+ bucketName: 'unscanned-bucket',
+ objectKey: 'dl/sample.pdf',
+ eTag: 'ASIAI44QH8DHBEXAMPLE',
+ versionId: 'd41d8cd98f00b204e9800998eEXAMPLE',
+ s3Throttled: false,
+ },
+ scanResultDetails: {
+ scanResultStatus: 'NO_THREATS_FOUND',
+ threats: null,
+ },
+ },
+};
+
+export const guardDutyNoThreadsFoundEvent: GuardDutyScanResultNotificationEvent =
+ {
+ ...baseEvent,
+ };
+
+export const guardDutyThreadsFoundEvent: GuardDutyScanResultNotificationEvent =
+ {
+ ...baseEvent,
+ detail: {
+ ...baseEvent.detail,
+ scanResultDetails: {
+ scanResultStatus: 'THREATS_FOUND',
+ threats: [
+ {
+ name: 'EICAR-Test-File (not a virus)',
+ },
+ ],
+ },
+ },
+ };
diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/container.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/container.test.ts
new file mode 100644
index 00000000..c0008c76
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/__tests__/container.test.ts
@@ -0,0 +1,85 @@
+import { mock } from 'jest-mock-extended';
+import { EventPublisher, logger } from 'utils';
+import { MoveFileHandler } from 'app/move-file-handler';
+import { createContainer } from 'container';
+import { loadConfig } from 'infra/config';
+
+jest.mock('utils', () => ({
+ logger: {
+ info: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn(),
+ debug: jest.fn(),
+ },
+ EventPublisher: jest.fn(),
+ eventBridgeClient: {},
+ sqsClient: {},
+}));
+
+jest.mock('app/move-file-handler');
+jest.mock('infra/config');
+
+describe('createContainer', () => {
+ const mockConfig = {
+ eventPublisherEventBusArn:
+ 'arn:aws:events:eu-west-2:123456789012:event-bus/test-bus',
+ eventPublisherDlqUrl:
+ 'https://sqs.eu-west-2.amazonaws.com/123456789012/test-dlq',
+ environment: 'test',
+ keyPrefixUnscannedFiles: 'dl/',
+ unscannedFileS3BucketName: 'unscanned-bucket',
+ safeFileS3BucketName: 'safe-bucket',
+ quarantineFileS3BucketName: 'quarantine-bucket',
+ };
+
+ const mockMoveFileHandler = mock();
+ const mockEventPublisher = mock();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ (loadConfig as jest.Mock).mockReturnValue(mockConfig);
+ (MoveFileHandler as jest.Mock).mockImplementation(
+ () => mockMoveFileHandler,
+ );
+ (EventPublisher as jest.Mock).mockImplementation(() => mockEventPublisher);
+ });
+
+ it('creates and returns a container with all dependencies', async () => {
+ const container = await createContainer();
+
+ expect(container).toEqual({
+ moveFileHandler: mockMoveFileHandler,
+ logger,
+ eventPublisher: mockEventPublisher,
+ });
+ });
+
+ it('loads configuration', async () => {
+ await createContainer();
+
+ expect(loadConfig).toHaveBeenCalledTimes(1);
+ });
+
+ it('creates MoveFileHandler with logger and config', async () => {
+ await createContainer();
+
+ expect(MoveFileHandler).toHaveBeenCalledTimes(1);
+ expect(MoveFileHandler).toHaveBeenCalledWith(logger, mockConfig);
+ });
+
+ it('creates EventPublisher instance with config', async () => {
+ await createContainer();
+
+ expect(EventPublisher).toHaveBeenCalledTimes(1);
+ expect(EventPublisher).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventBusArn: mockConfig.eventPublisherEventBusArn,
+ dlqUrl: mockConfig.eventPublisherDlqUrl,
+ logger,
+ sqsClient: expect.any(Object),
+ eventBridgeClient: expect.any(Object),
+ }),
+ );
+ });
+});
diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/domain/mapper.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/domain/mapper.test.ts
new file mode 100644
index 00000000..f92f1ee2
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/__tests__/domain/mapper.test.ts
@@ -0,0 +1,122 @@
+import { createFileQuarantinedEvent, createFileSafeEvent } from 'domain/mapper';
+import fileSafeValidator from 'digital-letters-events/FileSafe.js';
+import fileQuarantinedValidator from 'digital-letters-events/FileQuarantined.js';
+
+// Mock randomUUID to make tests deterministic
+jest.mock('node:crypto', () => ({
+ randomUUID: jest.fn(() => 'mocked-uuid-12345'),
+}));
+
+const createdAt = '2024-01-15T10:30:00.000Z';
+
+describe('mapper', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Mock Date to make tests deterministic
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2024-01-15T10:30:00.000Z'));
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('createFileSafeEvent', () => {
+ it('creates a FileSafe event with correct structure', () => {
+ const messageReference = 'msg-ref-123';
+ const senderId = 'sender-456';
+ const letterUri = 's3://safe-bucket/path/to/file.pdf';
+
+ const result = createFileSafeEvent(
+ messageReference,
+ senderId,
+ letterUri,
+ createdAt,
+ );
+
+ expect(result).toEqual({
+ specversion: '1.0',
+ id: 'mocked-uuid-12345',
+ subject: `customer/${senderId}/recipient/${messageReference}`,
+ source:
+ '/nhs/england/notify/production/primary/data-plane/digitalletters/print',
+ traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
+ type: 'uk.nhs.notify.digital.letters.print.file.safe.v1',
+ time: '2024-01-15T10:30:00.000Z',
+ data: {
+ messageReference,
+ senderId,
+ letterUri,
+ createdAt,
+ },
+ recordedtime: '2024-01-15T10:30:00.000Z',
+ severitynumber: 2,
+ });
+ const isValid = fileSafeValidator(result);
+ if (!isValid) {
+ throw new Error(JSON.stringify(fileSafeValidator.errors, null, 2));
+ }
+ expect(isValid).toBe(true);
+ });
+
+ it('handles different input values correctly', () => {
+ const result = createFileSafeEvent(
+ 'different-msg-ref',
+ 'different-sender',
+ 'https://another-bucket/another/path.pdf',
+ createdAt,
+ );
+
+ expect(result.data.messageReference).toBe('different-msg-ref');
+ expect(result.data.senderId).toBe('different-sender');
+ expect(result.data.letterUri).toBe(
+ 'https://another-bucket/another/path.pdf',
+ );
+ expect(result.data.createdAt).toBe(createdAt);
+ expect(result.type).toBe(
+ 'uk.nhs.notify.digital.letters.print.file.safe.v1',
+ );
+ });
+ });
+
+ describe('createFileQuarantinedEvent', () => {
+ it('creates a FileQuarantined event with correct structure', () => {
+ const messageReference = 'msg-ref-789';
+ const senderId = 'sender-012';
+ const letterUri = 's3://quarantine-bucket/path/to/infected.pdf';
+
+ const result = createFileQuarantinedEvent(
+ messageReference,
+ senderId,
+ letterUri,
+ createdAt,
+ );
+
+ expect(result).toEqual({
+ specversion: '1.0',
+ id: 'mocked-uuid-12345',
+ subject: `customer/${senderId}/recipient/${messageReference}`,
+ source:
+ '/nhs/england/notify/production/primary/data-plane/digitalletters/print',
+ traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
+ type: 'uk.nhs.notify.digital.letters.print.file.quarantined.v1',
+ time: '2024-01-15T10:30:00.000Z',
+ data: {
+ messageReference,
+ senderId,
+ letterUri,
+ createdAt,
+ },
+ recordedtime: '2024-01-15T10:30:00.000Z',
+ severitynumber: 2,
+ });
+ const isValid = fileQuarantinedValidator(result);
+ if (!isValid) {
+ throw new Error(
+ JSON.stringify(fileQuarantinedValidator.errors, null, 2),
+ );
+ }
+ expect(isValid).toBe(true);
+ });
+ });
+});
diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/index.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/index.test.ts
new file mode 100644
index 00000000..ee9c6f51
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/__tests__/index.test.ts
@@ -0,0 +1,96 @@
+import type { SQSEvent, SQSRecord } from 'aws-lambda';
+import { handler } from 'index';
+import { createContainer } from 'container';
+import { createHandler as createSqsHandler } from 'apis/sqs-handler';
+import type { SqsHandlerDependencies } from 'apis/sqs-handler';
+import { mock } from 'jest-mock-extended';
+
+jest.mock('container');
+jest.mock('apis/sqs-handler');
+
+const createSqsEvent = (recordCount: number): SQSEvent => ({
+ Records: Array.from(
+ { length: recordCount },
+ (_, i): SQSRecord => ({
+ messageId: `message-id-${i + 1}`,
+ receiptHandle: `receipt-handle-${i + 1}`,
+ body: JSON.stringify({
+ detail: {
+ id: `event-id-${i + 1}`,
+ source: 'test',
+ specversion: '1.0',
+ type: 'test.event',
+ time: '2025-12-16T10:00:00Z',
+ datacontenttype: 'application/json',
+ data: {},
+ },
+ }),
+ attributes: {
+ ApproximateReceiveCount: '1',
+ SentTimestamp: '1234567890',
+ SenderId: 'sender-id',
+ ApproximateFirstReceiveTimestamp: '1234567890',
+ },
+ messageAttributes: {},
+ md5OfBody: 'md5',
+ eventSource: 'aws:sqs',
+ eventSourceARN: 'arn:aws:sqs:region:account:queue',
+ awsRegion: 'eu-west-2',
+ }),
+ ),
+});
+
+describe('Lambda handler', () => {
+ const mockContainer = mock();
+ const mockSqsHandler = jest.fn();
+ const mockCreateContainer = jest.mocked(createContainer);
+ const mockCreateSqsHandler = jest.mocked(createSqsHandler);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockCreateContainer.mockResolvedValue(mockContainer);
+ mockCreateSqsHandler.mockReturnValue(mockSqsHandler);
+ mockSqsHandler.mockResolvedValue({ batchItemFailures: [] });
+ });
+
+ it('creates an SQS handler with the container dependencies and handler being invoked', async () => {
+ const sqsEvent = createSqsEvent(1);
+
+ await handler(sqsEvent);
+
+ expect(mockCreateSqsHandler).toHaveBeenCalledTimes(1);
+ expect(mockCreateSqsHandler).toHaveBeenCalledWith(mockContainer);
+ expect(mockSqsHandler).toHaveBeenCalledTimes(1);
+ expect(mockSqsHandler).toHaveBeenCalledWith(sqsEvent);
+ });
+
+ it('when fails to process a message it returns the id of the failed message', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const expectedResult = {
+ batchItemFailures: [{ itemIdentifier: 'message-id-1' }],
+ };
+ mockSqsHandler.mockResolvedValue(expectedResult);
+
+ const result = await handler(sqsEvent);
+
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('propagates errors from createContainer', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const error = new Error('Failed to create container');
+ mockCreateContainer.mockRejectedValue(error);
+
+ await expect(handler(sqsEvent)).rejects.toThrow(
+ 'Failed to create container',
+ );
+ });
+
+ it('propagates errors from the SQS handler', async () => {
+ const sqsEvent = createSqsEvent(1);
+ const error = new Error('Handler failed');
+ mockSqsHandler.mockRejectedValue(error);
+
+ await expect(handler(sqsEvent)).rejects.toThrow('Handler failed');
+ });
+});
diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/infra/config.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/infra/config.test.ts
new file mode 100644
index 00000000..15b9f91b
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/__tests__/infra/config.test.ts
@@ -0,0 +1,84 @@
+import { MoveScannedFilesConfig, loadConfig } from 'infra/config';
+import { defaultConfigReader } from 'utils';
+
+jest.mock('utils', () => ({
+ defaultConfigReader: {
+ getValue: jest.fn(),
+ },
+}));
+
+describe('loadConfig', () => {
+ const mockGetValue = jest.mocked(defaultConfigReader.getValue);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('loads all configuration values from environment', () => {
+ const mockConfig = {
+ eventPublisherEventBusArn:
+ 'arn:aws:events:eu-west-2:123456789012:event-bus/test-bus',
+ eventPublisherDlqUrl:
+ 'https://sqs.eu-west-2.amazonaws.com/123456789012/test-dlq',
+ environment: 'test',
+ keyPrefixUnscannedFiles: 'dl/',
+ unscannedFileS3BucketName: 'unscanned-bucket',
+ safeFileS3BucketName: 'safe-bucket',
+ quarantineFileS3BucketName: 'quarantine-bucket',
+ };
+
+ mockGetValue
+ .mockReturnValueOnce(mockConfig.eventPublisherEventBusArn)
+ .mockReturnValueOnce(mockConfig.eventPublisherDlqUrl)
+ .mockReturnValueOnce(mockConfig.keyPrefixUnscannedFiles)
+ .mockReturnValueOnce(mockConfig.unscannedFileS3BucketName)
+ .mockReturnValueOnce(mockConfig.safeFileS3BucketName)
+ .mockReturnValueOnce(mockConfig.quarantineFileS3BucketName)
+ .mockReturnValueOnce(mockConfig.environment);
+
+ const result = loadConfig();
+
+ expect(result).toEqual(mockConfig);
+ expect(mockGetValue).toHaveBeenCalledTimes(7);
+ expect(mockGetValue).toHaveBeenNthCalledWith(
+ 1,
+ 'EVENT_PUBLISHER_EVENT_BUS_ARN',
+ );
+ expect(mockGetValue).toHaveBeenNthCalledWith(2, 'EVENT_PUBLISHER_DLQ_URL');
+ expect(mockGetValue).toHaveBeenNthCalledWith(
+ 3,
+ 'KEY_PREFIX_UNSCANNED_FILES',
+ );
+ expect(mockGetValue).toHaveBeenNthCalledWith(
+ 4,
+ 'UNSCANNED_FILE_S3_BUCKET_NAME',
+ );
+ expect(mockGetValue).toHaveBeenNthCalledWith(5, 'SAFE_FILE_S3_BUCKET_NAME');
+ expect(mockGetValue).toHaveBeenNthCalledWith(
+ 6,
+ 'QUARANTINE_FILE_S3_BUCKET_NAME',
+ );
+ expect(mockGetValue).toHaveBeenNthCalledWith(7, 'ENVIRONMENT');
+ });
+
+ it('returns config with correct types', () => {
+ mockGetValue
+ .mockReturnValueOnce('arn:test')
+ .mockReturnValueOnce('https://dlq')
+ .mockReturnValueOnce('dl/')
+ .mockReturnValueOnce('unscanned-bucket')
+ .mockReturnValueOnce('safe-bucket')
+ .mockReturnValueOnce('quarantine-bucket')
+ .mockReturnValueOnce('prod');
+
+ const result: MoveScannedFilesConfig = loadConfig();
+
+ expect(typeof result.eventPublisherEventBusArn).toBe('string');
+ expect(typeof result.eventPublisherDlqUrl).toBe('string');
+ expect(typeof result.environment).toBe('string');
+ expect(typeof result.keyPrefixUnscannedFiles).toBe('string');
+ expect(typeof result.unscannedFileS3BucketName).toBe('string');
+ expect(typeof result.safeFileS3BucketName).toBe('string');
+ expect(typeof result.quarantineFileS3BucketName).toBe('string');
+ });
+});
diff --git a/lambdas/move-scanned-files-lambda/src/apis/sqs-handler.ts b/lambdas/move-scanned-files-lambda/src/apis/sqs-handler.ts
new file mode 100644
index 00000000..f142e35f
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/apis/sqs-handler.ts
@@ -0,0 +1,91 @@
+import type {
+ GuardDutyScanResultNotificationEventDetail,
+ SQSBatchItemFailure,
+ SQSBatchResponse,
+ SQSEvent,
+ SQSRecord,
+} from 'aws-lambda';
+import { EventPublisher, Logger } from 'utils';
+import { FileQuarantined, FileSafe } from 'digital-letters-events';
+import fileSafeValidator from 'digital-letters-events/FileSafe.js';
+import fileQuarantinedValidator from 'digital-letters-events/FileQuarantined.js';
+import { parseSqsRecord } from 'app/parse-sqs-message';
+import { MoveFileHandler } from 'app/move-file-handler';
+
+export interface SqsHandlerDependencies {
+ logger: Logger;
+ eventPublisher: EventPublisher;
+ moveFileHandler: MoveFileHandler;
+}
+
+export const createHandler = ({
+ eventPublisher,
+ logger,
+ moveFileHandler,
+}: SqsHandlerDependencies) =>
+ async function handler(sqsEvent: SQSEvent): Promise {
+ const receivedItemCount = sqsEvent.Records.length;
+
+ logger.info({
+ description: `Received SQS Event of ${receivedItemCount} record(s)`,
+ });
+
+ const batchItemFailures: SQSBatchItemFailure[] = [];
+ const fileSafeEvents: FileSafe[] = [];
+ const fileQuarantinedEvents: FileQuarantined[] = [];
+
+ let incoming: GuardDutyScanResultNotificationEventDetail;
+
+ await Promise.all(
+ sqsEvent.Records.map(async (sqsRecord: SQSRecord) => {
+ try {
+ incoming = parseSqsRecord(sqsRecord, logger);
+
+ const eventToPublish = await moveFileHandler.handle(incoming);
+
+ if (eventToPublish) {
+ if (eventToPublish.fileSafe) {
+ fileSafeEvents.push(eventToPublish.fileSafe);
+ }
+ if (eventToPublish.fileQuarantined) {
+ fileQuarantinedEvents.push(eventToPublish.fileQuarantined);
+ }
+ } else {
+ // there was something wrong with the event
+ batchItemFailures.push({ itemIdentifier: sqsRecord.messageId });
+ }
+ } catch (error: any) {
+ logger.warn({
+ error: error.message,
+ description: 'Failed processing message',
+ messageId: sqsRecord.messageId,
+ });
+ // this might be a transient error so we notify the queue to retry
+ batchItemFailures.push({ itemIdentifier: sqsRecord.messageId });
+ }
+ }),
+ );
+
+ await Promise.all(
+ [
+ fileSafeEvents.length > 0 &&
+ eventPublisher.sendEvents(
+ fileSafeEvents,
+ fileSafeValidator,
+ ),
+ fileQuarantinedEvents.length > 0 &&
+ eventPublisher.sendEvents(
+ fileQuarantinedEvents,
+ fileQuarantinedValidator,
+ ),
+ ].filter(Boolean),
+ );
+
+ const processedItemCount = receivedItemCount - batchItemFailures.length;
+
+ logger.info({
+ description: `${processedItemCount} of ${receivedItemCount} records processed successfully`,
+ });
+
+ return { batchItemFailures };
+ };
diff --git a/lambdas/move-scanned-files-lambda/src/app/move-file-handler.ts b/lambdas/move-scanned-files-lambda/src/app/move-file-handler.ts
new file mode 100644
index 00000000..92ea2438
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/app/move-file-handler.ts
@@ -0,0 +1,256 @@
+import { Logger } from 'utils/logger';
+import { S3Location, copyAndDeleteObjectS3, getS3ObjectMetadata } from 'utils';
+import { MoveScannedFilesConfig } from 'infra/config';
+import { GuardDutyScanResultNotificationEventDetail } from 'aws-lambda';
+import { FileQuarantined, FileSafe } from 'digital-letters-events';
+import { createFileQuarantinedEvent, createFileSafeEvent } from 'domain/mapper';
+
+export type MoveFileHandlerDependencies = {
+ logger: Logger;
+ scannedFilesConfig: MoveScannedFilesConfig;
+};
+
+export type EventToPublish = {
+ fileSafe?: FileSafe;
+ fileQuarantined?: FileQuarantined;
+};
+
+type ObjectMetadata = {
+ senderId: string;
+ messageReference: string;
+ createdAt: string;
+};
+
+type ObjectsFromEvent = {
+ eventToPublish: EventToPublish;
+ source: S3Location;
+ destination: S3Location;
+};
+
+const OBJECT_KEY_DISPLAY_LAST_CHARACTERS = 10;
+const SCAN_STATUS_COMPLETED = 'COMPLETED';
+const SCAN_RESULT_STATUS_NO_THREATS_FOUND = 'NO_THREATS_FOUND';
+
+const METADATA_CREATED_AT = 'createdat';
+const METADATA_MESSAGE_REFERENCE = 'messagereference';
+const METADATA_SENDER_ID = 'senderid';
+
+/**
+ * Utility function to extract a subset of the object key for logging purposes.
+ * @param key
+ * @returns
+ */
+function getLastCharactersOfKey(key: string): string {
+ return key.slice(-OBJECT_KEY_DISPLAY_LAST_CHARACTERS);
+}
+
+export class MoveFileHandler {
+ private readonly logger: Logger;
+
+ private readonly keyPrefixUnscannedFiles: string;
+
+ private readonly unscannedBucketName: string;
+
+ private readonly safeBucketName: string;
+
+ private readonly quarantineBucketName: string;
+
+ constructor(logger: Logger, scannedFilesConfig: MoveScannedFilesConfig) {
+ this.logger = logger;
+ this.keyPrefixUnscannedFiles = scannedFilesConfig.keyPrefixUnscannedFiles;
+ this.unscannedBucketName = scannedFilesConfig.unscannedFileS3BucketName;
+ this.safeBucketName = scannedFilesConfig.safeFileS3BucketName;
+ this.quarantineBucketName = scannedFilesConfig.quarantineFileS3BucketName;
+ }
+
+ /**
+ * As the guard duty scan result events are account wide, we need to ensure that the event is for
+ * digital letters files before processing it.
+ * @param eventDetail
+ * @returns
+ */
+ isEventForDigitalLetters(
+ eventDetail: GuardDutyScanResultNotificationEventDetail,
+ ): boolean {
+ const objectDetails = eventDetail.s3ObjectDetails;
+ if (
+ objectDetails.bucketName !== this.unscannedBucketName ||
+ !objectDetails.objectKey.startsWith(this.keyPrefixUnscannedFiles)
+ ) {
+ this.logger.warn({
+ description:
+ 'Received scan result for file not in unscanned bucket, ignoring event.',
+ expectedUnscannedBucketName: this.unscannedBucketName,
+ expectedKeyPrefix: this.keyPrefixUnscannedFiles,
+ receivedBucketName: objectDetails.bucketName,
+ receivedObjectKey: objectDetails.objectKey,
+ });
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Utility function to get the required metadata fields of an object stored in S3.
+ * @param objectDetails
+ * @returns the metadata or null if one of the required fields is missing.
+ */
+ async extractMetadata(objectDetails: {
+ bucketName: string;
+ objectKey: string;
+ }): Promise {
+ const source: S3Location = {
+ Bucket: objectDetails.bucketName,
+ Key: objectDetails.objectKey,
+ };
+
+ const metadata: Record | undefined =
+ await getS3ObjectMetadata(source);
+
+ this.logger.debug({
+ description: 'Fetched object metadata',
+ metadata,
+ });
+
+ if (!metadata) {
+ return null;
+ }
+
+ const metadataMap = new Map(Object.entries(metadata));
+
+ const createdAt = metadataMap.get(METADATA_CREATED_AT);
+ const messageReference = metadataMap.get(METADATA_MESSAGE_REFERENCE);
+ const senderId = metadataMap.get(METADATA_SENDER_ID);
+
+ if (!messageReference || !senderId || !createdAt) {
+ return null;
+ }
+
+ this.logger.info({
+ description: 'Fetched object metadata has all the required fields',
+ senderId,
+ messageReference,
+ createdAt,
+ });
+
+ return {
+ senderId,
+ messageReference,
+ createdAt,
+ };
+ }
+
+ createObjectsFromData(
+ scanStatus: string,
+ objectKey: string,
+ scanDetail: GuardDutyScanResultNotificationEventDetail,
+ metadata: ObjectMetadata,
+ ): ObjectsFromEvent {
+ const eventToPublish: EventToPublish = {};
+ let destinationBucket;
+
+ if (
+ scanStatus === SCAN_STATUS_COMPLETED &&
+ scanDetail.scanResultDetails.scanResultStatus ===
+ SCAN_RESULT_STATUS_NO_THREATS_FOUND
+ ) {
+ destinationBucket = this.safeBucketName;
+ eventToPublish.fileSafe = createFileSafeEvent(
+ metadata.messageReference,
+ metadata.senderId,
+ `s3://${destinationBucket}/${objectKey}`,
+ metadata.createdAt,
+ );
+ } else {
+ destinationBucket = this.quarantineBucketName;
+ this.logger.warn({
+ description: 'File scan did not complete successfully',
+ scanStatus,
+ scanResultDetails: scanDetail.scanResultDetails,
+ key: getLastCharactersOfKey(objectKey),
+ messageReference: metadata.messageReference,
+ senderId: metadata.senderId,
+ });
+ eventToPublish.fileQuarantined = createFileQuarantinedEvent(
+ metadata.messageReference,
+ metadata.senderId,
+ `s3://${destinationBucket}/${objectKey}`,
+ metadata.createdAt,
+ );
+ }
+
+ const source: S3Location = {
+ Bucket: this.unscannedBucketName,
+ Key: objectKey,
+ };
+
+ const destination: S3Location = {
+ Bucket: destinationBucket,
+ Key: objectKey,
+ };
+
+ return {
+ source,
+ destination,
+ eventToPublish,
+ };
+ }
+
+ public async handle(
+ scanDetail: GuardDutyScanResultNotificationEventDetail,
+ ): Promise {
+ const { scanStatus } = scanDetail;
+ const objectDetails = scanDetail.s3ObjectDetails;
+
+ this.logger.info({
+ description: 'Processing scan result',
+ scanStatus,
+ subkey: getLastCharactersOfKey(objectDetails.objectKey),
+ });
+
+ if (!(await this.isEventForDigitalLetters(scanDetail))) {
+ this.logger.warn({
+ description: 'Scan result event is not valid, skipping processing.',
+ sourceBucket: objectDetails.bucketName,
+ key: objectDetails.objectKey, // Full key to make it easier when investigating issues as all events should pass this validation.
+ });
+ return null;
+ }
+ const metadata = await this.extractMetadata(objectDetails);
+ if (!metadata) {
+ this.logger.error({
+ description:
+ 'Failed to extract required metadata from scanned file, skipping processing.',
+ subkey: getLastCharactersOfKey(objectDetails.objectKey),
+ });
+ return null;
+ }
+
+ const { destination, eventToPublish, source } = this.createObjectsFromData(
+ scanStatus,
+ objectDetails.objectKey,
+ scanDetail,
+ metadata,
+ );
+
+ this.logger.debug({
+ description: 'Going to move file to destination bucket',
+ scanStatus,
+ destinationLocation: destination.Bucket,
+ subkey: getLastCharactersOfKey(objectDetails.objectKey),
+ messageReference: metadata.messageReference,
+ senderId: metadata.senderId,
+ });
+
+ await copyAndDeleteObjectS3(source, destination);
+
+ this.logger.info({
+ description: 'Moved file to destination bucket',
+ scanStatus,
+ messageReference: metadata.messageReference,
+ senderId: metadata.senderId,
+ });
+
+ return eventToPublish;
+ }
+}
diff --git a/lambdas/move-scanned-files-lambda/src/app/parse-sqs-message.ts b/lambdas/move-scanned-files-lambda/src/app/parse-sqs-message.ts
new file mode 100644
index 00000000..c92dbcf8
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/app/parse-sqs-message.ts
@@ -0,0 +1,24 @@
+import {
+ GuardDutyScanResultNotificationEventDetail,
+ SQSRecord,
+} from 'aws-lambda';
+import { Logger } from 'utils';
+
+export const parseSqsRecord = (
+ sqsRecord: SQSRecord,
+ logger: Logger,
+): GuardDutyScanResultNotificationEventDetail => {
+ logger.info({
+ description: 'Parsing SQS Record',
+ messageId: sqsRecord.messageId,
+ });
+
+ const sqsEventBody = JSON.parse(sqsRecord.body);
+ const sqsEventDetail = sqsEventBody.detail;
+
+ logger.debug({
+ description: 'Returning detail as GuardDutyScanResultNotificationEvent',
+ detail: sqsEventDetail,
+ });
+ return sqsEventDetail as GuardDutyScanResultNotificationEventDetail;
+};
diff --git a/lambdas/move-scanned-files-lambda/src/container.ts b/lambdas/move-scanned-files-lambda/src/container.ts
new file mode 100644
index 00000000..868249f9
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/container.ts
@@ -0,0 +1,26 @@
+import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils';
+import type { SqsHandlerDependencies } from 'apis/sqs-handler';
+import { loadConfig } from 'infra/config';
+import { MoveFileHandler } from 'app/move-file-handler';
+
+export async function createContainer(): Promise {
+ const config = loadConfig();
+
+ const { eventPublisherDlqUrl, eventPublisherEventBusArn } = config;
+
+ const eventPublisher = new EventPublisher({
+ eventBusArn: eventPublisherEventBusArn,
+ dlqUrl: eventPublisherDlqUrl,
+ logger,
+ sqsClient,
+ eventBridgeClient,
+ });
+
+ const moveFileHandler = new MoveFileHandler(logger, config);
+
+ return {
+ logger,
+ moveFileHandler,
+ eventPublisher,
+ };
+}
diff --git a/lambdas/move-scanned-files-lambda/src/domain/mapper.ts b/lambdas/move-scanned-files-lambda/src/domain/mapper.ts
new file mode 100644
index 00000000..fe0d483a
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/domain/mapper.ts
@@ -0,0 +1,62 @@
+import { randomUUID } from 'node:crypto';
+import { FileQuarantined, FileSafe } from 'digital-letters-events';
+
+function createEventWithCommonFields(
+ isFileSafe: boolean,
+ messageReference: string,
+ senderId: string,
+ letterUri: string,
+ createdAt: string,
+): FileSafe | FileQuarantined {
+ return {
+ specversion: '1.0',
+ id: randomUUID(),
+ subject: `customer/${senderId}/recipient/${messageReference}`,
+ source:
+ '/nhs/england/notify/production/primary/data-plane/digitalletters/print', // Note CCM-13892.
+ traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', // Note CCM-14255.
+ type: isFileSafe
+ ? 'uk.nhs.notify.digital.letters.print.file.safe.v1'
+ : 'uk.nhs.notify.digital.letters.print.file.quarantined.v1',
+ time: new Date().toISOString(),
+ data: {
+ messageReference,
+ senderId,
+ letterUri,
+ createdAt,
+ },
+
+ recordedtime: new Date().toISOString(),
+ severitynumber: 2,
+ };
+}
+
+export function createFileSafeEvent(
+ messageReference: string,
+ senderId: string,
+ letterUri: string,
+ createdAt: string,
+): FileSafe {
+ return createEventWithCommonFields(
+ true,
+ messageReference,
+ senderId,
+ letterUri,
+ createdAt,
+ ) as FileSafe;
+}
+
+export function createFileQuarantinedEvent(
+ messageReference: string,
+ senderId: string,
+ letterUri: string,
+ createdAt: string,
+): FileQuarantined {
+ return createEventWithCommonFields(
+ false,
+ messageReference,
+ senderId,
+ letterUri,
+ createdAt,
+ ) as FileQuarantined;
+}
diff --git a/lambdas/move-scanned-files-lambda/src/index.ts b/lambdas/move-scanned-files-lambda/src/index.ts
new file mode 100644
index 00000000..655bf505
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/index.ts
@@ -0,0 +1,10 @@
+// This is a Lambda entrypoint file.
+import type { SQSEvent } from 'aws-lambda';
+import { createContainer } from 'container';
+import { createHandler as createSqsHandler } from 'apis/sqs-handler';
+
+export const handler = async (sqsEvent: SQSEvent) => {
+ const container = await createContainer();
+ const sqsHandler = createSqsHandler(container);
+ return sqsHandler(sqsEvent);
+};
diff --git a/lambdas/move-scanned-files-lambda/src/infra/config.ts b/lambdas/move-scanned-files-lambda/src/infra/config.ts
new file mode 100644
index 00000000..96702b6d
--- /dev/null
+++ b/lambdas/move-scanned-files-lambda/src/infra/config.ts
@@ -0,0 +1,37 @@
+import { defaultConfigReader } from 'utils';
+
+export type MoveScannedFilesConfig = {
+ eventPublisherEventBusArn: string;
+ eventPublisherDlqUrl: string;
+ environment: string;
+ unscannedFileS3BucketName: string;
+ safeFileS3BucketName: string;
+ quarantineFileS3BucketName: string;
+ keyPrefixUnscannedFiles: string;
+};
+
+export function loadConfig(): MoveScannedFilesConfig {
+ return {
+ eventPublisherEventBusArn: defaultConfigReader.getValue(
+ 'EVENT_PUBLISHER_EVENT_BUS_ARN',
+ ),
+ eventPublisherDlqUrl: defaultConfigReader.getValue(
+ 'EVENT_PUBLISHER_DLQ_URL',
+ ),
+ // There is a limitation of how many buckets can be scanned with GuardDuty per account.
+ // As DL will share the same bucket with other services, this is a safeguard to only process events for files for digital letters.
+ keyPrefixUnscannedFiles: defaultConfigReader.getValue(
+ 'KEY_PREFIX_UNSCANNED_FILES',
+ ),
+ unscannedFileS3BucketName: defaultConfigReader.getValue(
+ 'UNSCANNED_FILE_S3_BUCKET_NAME',
+ ),
+ safeFileS3BucketName: defaultConfigReader.getValue(
+ 'SAFE_FILE_S3_BUCKET_NAME',
+ ),
+ quarantineFileS3BucketName: defaultConfigReader.getValue(
+ 'QUARANTINE_FILE_S3_BUCKET_NAME',
+ ),
+ environment: defaultConfigReader.getValue('ENVIRONMENT'),
+ };
+}
diff --git a/lambdas/move-scanned-files-lambda/tsconfig.json b/lambdas/move-scanned-files-lambda/tsconfig.json
new file mode 100644
index 00000000..f7bcaa1f
--- /dev/null
+++ b/lambdas/move-scanned-files-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/package-lock.json b/package-lock.json
index 7b16ce83..18579492 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,6 +21,7 @@
"lambdas/print-status-handler",
"lambdas/print-analyser",
"lambdas/report-event-transformer",
+ "lambdas/move-scanned-files-lambda",
"utils/utils",
"utils/sender-management",
"src/cloudevents",
@@ -955,6 +956,330 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
+ "lambdas/move-scanned-files-lambda": {
+ "name": "nhs-notify-digital-move-scanned-files-lambda",
+ "version": "0.0.1",
+ "dependencies": {
+ "aws-lambda": "^1.0.7",
+ "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",
+ "jest-mock-extended": "^3.0.7",
+ "typescript": "^5.9.3"
+ }
+ },
+ "lambdas/move-scanned-files-lambda/node_modules/@jest/core": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz",
+ "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/console": "^29.7.0",
+ "@jest/reporters": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "ansi-escapes": "^4.2.1",
+ "chalk": "^4.0.0",
+ "ci-info": "^3.2.0",
+ "exit": "^0.1.2",
+ "graceful-fs": "^4.2.9",
+ "jest-changed-files": "^29.7.0",
+ "jest-config": "^29.7.0",
+ "jest-haste-map": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-regex-util": "^29.6.3",
+ "jest-resolve": "^29.7.0",
+ "jest-resolve-dependencies": "^29.7.0",
+ "jest-runner": "^29.7.0",
+ "jest-runtime": "^29.7.0",
+ "jest-snapshot": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-validate": "^29.7.0",
+ "jest-watcher": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "pretty-format": "^29.7.0",
+ "slash": "^3.0.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "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/move-scanned-files-lambda/node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sinclair/typebox": "^0.27.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/move-scanned-files-lambda/node_modules/@jest/types": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
+ "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^3.0.0",
+ "@types/node": "*",
+ "@types/yargs": "^17.0.8",
+ "chalk": "^4.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/move-scanned-files-lambda/node_modules/@sinclair/typebox": {
+ "version": "0.27.10",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
+ "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "lambdas/move-scanned-files-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/move-scanned-files-lambda/node_modules/expect": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
+ "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/expect-utils": "^29.7.0",
+ "jest-get-type": "^29.6.3",
+ "jest-matcher-utils": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/move-scanned-files-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": {
+ "@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/move-scanned-files-lambda/node_modules/jest-cli": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz",
+ "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/core": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "chalk": "^4.0.0",
+ "create-jest": "^29.7.0",
+ "exit": "^0.1.2",
+ "import-local": "^3.0.2",
+ "jest-config": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-validate": "^29.7.0",
+ "yargs": "^17.3.1"
+ },
+ "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/move-scanned-files-lambda/node_modules/jest-message-util": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz",
+ "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.12.13",
+ "@jest/types": "^29.6.3",
+ "@types/stack-utils": "^2.0.0",
+ "chalk": "^4.0.0",
+ "graceful-fs": "^4.2.9",
+ "micromatch": "^4.0.4",
+ "pretty-format": "^29.7.0",
+ "slash": "^3.0.0",
+ "stack-utils": "^2.0.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/move-scanned-files-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/move-scanned-files-lambda/node_modules/jest-regex-util": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz",
+ "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/move-scanned-files-lambda/node_modules/jest-snapshot": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz",
+ "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.11.6",
+ "@babel/generator": "^7.7.2",
+ "@babel/plugin-syntax-jsx": "^7.7.2",
+ "@babel/plugin-syntax-typescript": "^7.7.2",
+ "@babel/types": "^7.3.3",
+ "@jest/expect-utils": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "babel-preset-current-node-syntax": "^1.0.0",
+ "chalk": "^4.0.0",
+ "expect": "^29.7.0",
+ "graceful-fs": "^4.2.9",
+ "jest-diff": "^29.7.0",
+ "jest-get-type": "^29.6.3",
+ "jest-matcher-utils": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "natural-compare": "^1.4.0",
+ "pretty-format": "^29.7.0",
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/move-scanned-files-lambda/node_modules/jest-util": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
+ "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "ci-info": "^3.2.0",
+ "graceful-fs": "^4.2.9",
+ "picomatch": "^2.2.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/move-scanned-files-lambda/node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "lambdas/move-scanned-files-lambda/node_modules/pretty-format": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
+ "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^18.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "lambdas/move-scanned-files-lambda/node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "dev": true,
+ "license": "MIT"
+ },
"lambdas/pdm-mock-lambda": {
"name": "nhs-notify-digital-letters-pdm-mock-lambda",
"version": "0.0.1",
@@ -19611,6 +19936,10 @@
"resolved": "lambdas/ttl-poll-lambda",
"link": true
},
+ "node_modules/nhs-notify-digital-move-scanned-files-lambda": {
+ "resolved": "lambdas/move-scanned-files-lambda",
+ "link": true
+ },
"node_modules/nise": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz",
diff --git a/package.json b/package.json
index a7a6e0e1..cac36007 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
"lambdas/print-status-handler",
"lambdas/print-analyser",
"lambdas/report-event-transformer",
+ "lambdas/move-scanned-files-lambda",
"utils/utils",
"utils/sender-management",
"src/cloudevents",
diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-file-quarantined-data.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-file-quarantined-data.schema.yaml
index ff69f70e..86254b2f 100644
--- a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-file-quarantined-data.schema.yaml
+++ b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-file-quarantined-data.schema.yaml
@@ -10,7 +10,10 @@ properties:
$ref: ../defs/requests.schema.yaml#/properties/senderId
letterUri:
$ref: ../defs/print.schema.yaml#/properties/letterUri
+ createdAt:
+ $ref: ../defs/print.schema.yaml#/properties/createdAt
required:
- messageReference
- senderId
- letterUri
+ - createdAt
diff --git a/tests/playwright/constants/backend-constants.ts b/tests/playwright/constants/backend-constants.ts
index fae4a1df..072cbc88 100644
--- a/tests/playwright/constants/backend-constants.ts
+++ b/tests/playwright/constants/backend-constants.ts
@@ -23,6 +23,8 @@ export const FILE_SCANNER_DLQ_NAME = `${CSI}-scanner-dlq`;
export const PRINT_STATUS_HANDLER_DLQ_NAME = `${CSI}-print-status-handler-dlq`;
export const HANDLE_TTL_DLQ_NAME = `${CSI}-ttl-handle-expiry-errors-queue`;
export const PRINT_ANALYSER_DLQ_NAME = `${CSI}-print-analyser-dlq`;
+export const MOVE_SCANNED_FILES_NAME = `${CSI}-move-scanned-files-queue`;
+export const MOVE_SCANNED_FILES_DLQ_NAME = `${CSI}-move-scanned-files-dlq`;
// Queue Url Prefix
export const SQS_URL_PREFIX = `https://sqs.${REGION}.amazonaws.com/${AWS_ACCOUNT_ID}/`;
@@ -38,6 +40,10 @@ export const TTL_TABLE_NAME = `${CSI}-ttl`;
// S3
export const LETTERS_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REGION}-${ENV}-dl-letters`;
export const FILE_SAFE_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REGION}-${ENV}-dl-file-safe`;
+export const UNSCANNED_FILES_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REGION}-main-acct-digi-unscanned-files`;
+export const FILE_QUARANTINE_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REGION}-${ENV}-dl-file-quarantine`;
+// Files that are scanned by Guardduty are in a bucket prefixed by the environment.
+export const PREFIX_DL_FILES = `${CSI}/`;
// Cloudwatch
export const PDM_UPLOADER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-uploader`;
@@ -46,3 +52,4 @@ export const CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-core-noti
export const FILE_SCANNER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-file-scanner`;
export const PRINT_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-print-status-handler`;
export const PRINT_ANALYSER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-print-analyser`;
+export const MOVE_SCANNED_FILES_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-move-scanned-files`;
diff --git a/tests/playwright/digital-letters-component-tests/move-scanned-files.component.spec.ts b/tests/playwright/digital-letters-component-tests/move-scanned-files.component.spec.ts
new file mode 100644
index 00000000..b52e4acc
--- /dev/null
+++ b/tests/playwright/digital-letters-component-tests/move-scanned-files.component.spec.ts
@@ -0,0 +1,210 @@
+import { expect, test } from '@playwright/test';
+import {
+ EVENT_BUS_LOG_GROUP_NAME,
+ FILE_QUARANTINE_S3_BUCKET_NAME,
+ FILE_SAFE_S3_BUCKET_NAME,
+ MOVE_SCANNED_FILES_DLQ_NAME,
+ MOVE_SCANNED_FILES_LAMBDA_LOG_GROUP_NAME,
+ PREFIX_DL_FILES,
+ UNSCANNED_FILES_S3_BUCKET_NAME,
+} from 'constants/backend-constants';
+import { SENDER_ID_SKIPS_NOTIFY } from 'constants/tests-constants';
+import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers';
+import expectToPassEventually from 'helpers/expectations';
+import { expectMessageContainingString } from 'helpers/sqs-helpers';
+import { v4 as uuidv4 } from 'uuid';
+import { PutObjectCommand } from '@aws-sdk/client-s3';
+import { getS3ObjectMetadata, s3Client } from 'utils';
+
+test.describe('Digital Letters - Move Scanned Files', () => {
+ test.beforeAll(async () => {
+ test.setTimeout(250_000);
+ });
+
+ test('given file without virus, when uploaded into S3 bucket and guardduty scan passes then a FileSafe event is triggered', async () => {
+ const messageReference = uuidv4();
+ const createdAt = new Date().toISOString();
+ const objectKey = `${PREFIX_DL_FILES}${messageReference}.txt`;
+
+ const body = Buffer.from('test file content');
+
+ const params = {
+ Bucket: UNSCANNED_FILES_S3_BUCKET_NAME,
+ Key: objectKey,
+ Body: body,
+ ContentType: 'application/pdf',
+ Metadata: {
+ messageReference,
+ senderId: SENDER_ID_SKIPS_NOTIFY,
+ createdAt,
+ },
+ };
+
+ await s3Client.send(new PutObjectCommand(params));
+
+ // Verify the event is processed and a message appears in the Lambda logs
+ await expectToPassEventually(async () => {
+ const filteredLogs = await getLogsFromCloudwatch(
+ MOVE_SCANNED_FILES_LAMBDA_LOG_GROUP_NAME,
+ [
+ '$.message.description = "Moved file to destination bucket"',
+ '$.message.scanStatus = "COMPLETED"',
+ `$.message.messageReference = "${messageReference}"`,
+ `$.message.senderId = "${SENDER_ID_SKIPS_NOTIFY}"`,
+ ],
+ );
+
+ expect(filteredLogs.length).toEqual(1);
+ }, 240);
+
+ // Verify the event is published in the event bus
+ await expectToPassEventually(async () => {
+ const expectedLetterUri = `s3://${FILE_SAFE_S3_BUCKET_NAME}/${objectKey}`;
+ const eventLogEntry = await getLogsFromCloudwatch(
+ EVENT_BUS_LOG_GROUP_NAME,
+ [
+ '$.message_type = "EVENT_RECEIPT"',
+ '$.details.detail_type = "uk.nhs.notify.digital.letters.print.file.safe.v1"',
+ `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`,
+ `$.details.event_detail = "*\\"senderId\\":\\"${SENDER_ID_SKIPS_NOTIFY}\\"*"`,
+ `$.details.event_detail = "*\\"createdAt\\":\\"${createdAt}\\"*"`,
+ `$.details.event_detail = "*\\"letterUri\\":\\"${expectedLetterUri}\\"*"`,
+ ],
+ );
+
+ expect(eventLogEntry.length).toEqual(1);
+ }, 240);
+
+ await expectToPassEventually(async () => {
+ const metadata = await getS3ObjectMetadata({
+ Bucket: FILE_SAFE_S3_BUCKET_NAME,
+ Key: objectKey,
+ });
+ expect(metadata).toBeDefined();
+ expect(metadata?.messagereference).toEqual(messageReference);
+ expect(metadata?.senderid).toEqual(SENDER_ID_SKIPS_NOTIFY);
+ expect(metadata?.createdat).toEqual(createdAt);
+
+ // check the file was deleted from the unscanned bucket
+ let originalMetadata;
+ try {
+ originalMetadata = await getS3ObjectMetadata({
+ Bucket: UNSCANNED_FILES_S3_BUCKET_NAME,
+ Key: objectKey,
+ });
+ } catch {
+ // expected error
+ }
+ expect(originalMetadata).toBeUndefined();
+ }, 240);
+ });
+
+ test('given file with EICAR virus, when uploaded into S3 bucket and guardduty scan completes then a FileQuarantine event is triggered', async () => {
+ const messageReference = uuidv4();
+ const createdAt = new Date().toISOString();
+ const objectKey = `${PREFIX_DL_FILES}${messageReference}.txt`;
+ // Divided in two strings in case it could trigger any antivirus software. Lint complains about unnecessary escape \P, so ignoring it
+ const part1EicarFile = 'X5O!P%@AP[4\PZX54(P^)7CC)7}$'; //eslint-disable-line
+ const part2EicarFile = 'EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*';
+
+ const body = Buffer.from(part1EicarFile + part2EicarFile);
+
+ const params = {
+ Bucket: UNSCANNED_FILES_S3_BUCKET_NAME,
+ Key: objectKey,
+ Body: body,
+ ContentType: 'application/pdf',
+ Metadata: {
+ messageReference,
+ senderId: SENDER_ID_SKIPS_NOTIFY,
+ createdAt,
+ },
+ };
+
+ await s3Client.send(new PutObjectCommand(params));
+
+ // Verify the event is processed and a message appears in the Lambda logs
+ await expectToPassEventually(async () => {
+ const filteredLogs = await getLogsFromCloudwatch(
+ MOVE_SCANNED_FILES_LAMBDA_LOG_GROUP_NAME,
+ [
+ '$.message.description = "Moved file to destination bucket"',
+ '$.message.scanStatus = "COMPLETED"',
+ `$.message.messageReference = "${messageReference}"`,
+ `$.message.senderId = "${SENDER_ID_SKIPS_NOTIFY}"`,
+ ],
+ );
+
+ expect(filteredLogs.length).toEqual(1);
+ }, 240);
+
+ // Verify the event is published in the event bus
+ await expectToPassEventually(async () => {
+ const expectedLetterUri = `s3://${FILE_QUARANTINE_S3_BUCKET_NAME}/${objectKey}`;
+ const eventLogEntry = await getLogsFromCloudwatch(
+ EVENT_BUS_LOG_GROUP_NAME,
+ [
+ '$.message_type = "EVENT_RECEIPT"',
+ '$.details.detail_type = "uk.nhs.notify.digital.letters.print.file.quarantined.v1"',
+ `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`,
+ `$.details.event_detail = "*\\"senderId\\":\\"${SENDER_ID_SKIPS_NOTIFY}\\"*"`,
+ `$.details.event_detail = "*\\"createdAt\\":\\"${createdAt}\\"*"`,
+ `$.details.event_detail = "*\\"letterUri\\":\\"${expectedLetterUri}\\"*"`,
+ ],
+ );
+
+ expect(eventLogEntry.length).toEqual(1);
+ }, 240);
+
+ await expectToPassEventually(async () => {
+ const metadata = await getS3ObjectMetadata({
+ Bucket: FILE_QUARANTINE_S3_BUCKET_NAME,
+ Key: objectKey,
+ });
+ expect(metadata).toBeDefined();
+ expect(metadata?.messagereference).toEqual(messageReference);
+ expect(metadata?.senderid).toEqual(SENDER_ID_SKIPS_NOTIFY);
+ expect(metadata?.createdat).toEqual(createdAt);
+
+ // check the file was deleted from the unscanned bucket
+ let originalMetadata;
+ try {
+ originalMetadata = await getS3ObjectMetadata({
+ Bucket: UNSCANNED_FILES_S3_BUCKET_NAME,
+ Key: objectKey,
+ });
+ } catch {
+ // expected error
+ }
+ expect(originalMetadata).toBeUndefined();
+ }, 240);
+ });
+
+ test('given file without REQUIRED metadata, when uploaded into S3 bucket and guardduty scan passes then it goes to DLQ', async () => {
+ test.setTimeout(550_000);
+ const messageReference = uuidv4();
+ const objectKey = `${PREFIX_DL_FILES}${messageReference}.txt`;
+
+ const body = Buffer.from('test file content');
+
+ const params = {
+ Bucket: UNSCANNED_FILES_S3_BUCKET_NAME,
+ Key: objectKey,
+ Body: body,
+ ContentType: 'application/pdf',
+ Metadata: {
+ messageReference,
+ // senderId and createdAt are missing
+ },
+ };
+
+ await s3Client.send(new PutObjectCommand(params));
+
+ // Verify there is a message in the DLQ
+ await expectMessageContainingString(
+ MOVE_SCANNED_FILES_DLQ_NAME,
+ objectKey,
+ 500,
+ );
+ });
+});
diff --git a/utils/utils/src/__tests__/s3-utils/copy-and-delete-object-s3.test.ts b/utils/utils/src/__tests__/s3-utils/copy-and-delete-object-s3.test.ts
new file mode 100644
index 00000000..7a65841b
--- /dev/null
+++ b/utils/utils/src/__tests__/s3-utils/copy-and-delete-object-s3.test.ts
@@ -0,0 +1,34 @@
+import { mockClient } from 'aws-sdk-client-mock';
+import 'aws-sdk-client-mock-jest';
+import {
+ CopyObjectCommand,
+ DeleteObjectCommand,
+ S3Client,
+} from '@aws-sdk/client-s3';
+import { copyAndDeleteObjectS3 } from '../../s3-utils/copy-and-delete-object-s3';
+
+const s3Client = mockClient(S3Client);
+
+it('puts data in S3', async () => {
+ await copyAndDeleteObjectS3(
+ {
+ Bucket: 'sourceBucket',
+ Key: 'sourceKey',
+ },
+ {
+ Bucket: 'destinationBucket',
+ Key: 'destinationKey',
+ },
+ );
+
+ expect(s3Client).toHaveReceivedCommandWith(CopyObjectCommand, {
+ Bucket: 'destinationBucket',
+ CopySource: '/sourceBucket/sourceKey',
+ Key: 'destinationKey',
+ });
+
+ expect(s3Client).toHaveReceivedCommandWith(DeleteObjectCommand, {
+ Bucket: 'sourceBucket',
+ Key: 'sourceKey',
+ });
+});
diff --git a/utils/utils/src/s3-utils/copy-and-delete-object-s3.ts b/utils/utils/src/s3-utils/copy-and-delete-object-s3.ts
new file mode 100644
index 00000000..1ded77e9
--- /dev/null
+++ b/utils/utils/src/s3-utils/copy-and-delete-object-s3.ts
@@ -0,0 +1,25 @@
+/* eslint-disable no-console */
+import { CopyObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
+import type { S3Location } from './get-object-s3';
+import { s3Client } from './s3-client';
+
+export async function copyAndDeleteObjectS3(
+ source: S3Location,
+ destination: S3Location,
+): Promise {
+ try {
+ const copyParams = {
+ Bucket: destination.Bucket,
+ CopySource: `/${source.Bucket}/${source.Key}`,
+ Key: destination.Key,
+ };
+
+ await s3Client.send(new CopyObjectCommand(copyParams));
+
+ await s3Client.send(new DeleteObjectCommand(source));
+ } catch (error) {
+ throw new Error(
+ `Move of ${source.Bucket}/${source.Key} to ${destination.Bucket}/${destination.Key} failed, error: ${error}`,
+ );
+ }
+}
diff --git a/utils/utils/src/s3-utils/index.ts b/utils/utils/src/s3-utils/index.ts
index 867e20cf..894af46f 100644
--- a/utils/utils/src/s3-utils/index.ts
+++ b/utils/utils/src/s3-utils/index.ts
@@ -2,3 +2,4 @@ export * from './get-object-s3';
export * from './s3-client';
export * from './put-data-s3';
export * from './put-file-s3';
+export * from './copy-and-delete-object-s3';