From 4e8dfb91c313767b681affa42b86c69f06d5c8d3 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Fri, 15 May 2026 11:47:27 +0100 Subject: [PATCH 1/8] CCM-15087: Verify contact details --- .../terraform/components/sbx/README.md | 1 + .../terraform/components/sbx/outputs.tf | 4 + .../terraform/modules/backend-api/README.md | 2 + .../iam_role_api_gateway_execution_role.tf | 1 + .../terraform/modules/backend-api/locals.tf | 1 + .../module_verify_contact_detail_lambda.tf | 90 ++++++++ .../terraform/modules/backend-api/outputs.tf | 4 + .../modules/backend-api/spec.tmpl.json | 135 +++++++++-- lambdas/backend-api/build.sh | 3 +- .../api/create-contact-details.test.ts | 11 +- .../__tests__/api/get-contact-details.test.ts | 4 +- .../api/list-contact-details.test.ts | 4 +- .../api/verify-contact-detail.test.ts | 171 ++++++++++++++ .../app/contact-details-client.test.ts | 216 ++++++++++++++++-- .../domain/hash-contact-details-otp.test.ts | 16 +- .../infra/contact-details-repository.test.ts | 170 +++++++++++++- lambdas/backend-api/src/api/responses.ts | 6 +- .../src/api/verify-contact-detail.ts | 54 +++++ .../src/app/contact-details-client.ts | 63 ++++- .../src/domain/hash-contact-details-otp.ts | 4 +- .../src/infra/contact-details-repository.ts | 129 +++++++++-- lambdas/backend-api/src/infra/otp-service.ts | 4 +- .../backend-api/src/verify-contact-detail.ts | 4 + .../__snapshots__/index.test.ts.snap | 30 ++- .../schemas/contact-details/index.test.ts | 67 +++++- lambdas/backend-client/src/schemas/client.ts | 2 +- .../src/schemas/contact-details/index.ts | 16 +- .../backend-client/src/types/error-cases.ts | 1 + packages/types/src/index.ts | 8 +- packages/types/src/types.gen.ts | 44 +++- tests/test-team/global.d.ts | 1 + .../helpers/db/contact-details-helper.ts | 43 ++++ .../factories/contact-details-factory.ts | 21 +- .../verify-contact-detail.api.spec.ts | 161 +++++++++++++ utils/backend-config/src/backend-config.ts | 8 + 35 files changed, 1377 insertions(+), 122 deletions(-) create mode 100644 infrastructure/terraform/modules/backend-api/module_verify_contact_detail_lambda.tf create mode 100644 lambdas/backend-api/src/__tests__/api/verify-contact-detail.test.ts create mode 100644 lambdas/backend-api/src/api/verify-contact-detail.ts create mode 100644 lambdas/backend-api/src/verify-contact-detail.ts create mode 100644 tests/test-team/template-mgmt-api-tests/verify-contact-detail.api.spec.ts diff --git a/infrastructure/terraform/components/sbx/README.md b/infrastructure/terraform/components/sbx/README.md index c074e2db68..0eed7ed6ce 100644 --- a/infrastructure/terraform/components/sbx/README.md +++ b/infrastructure/terraform/components/sbx/README.md @@ -38,6 +38,7 @@ | [client\_ssm\_path\_prefix](#output\_client\_ssm\_path\_prefix) | n/a | | [cognito\_user\_pool\_client\_id](#output\_cognito\_user\_pool\_client\_id) | n/a | | [cognito\_user\_pool\_id](#output\_cognito\_user\_pool\_id) | n/a | +| [contact\_details\_otp\_secret\_parameter\_name](#output\_contact\_details\_otp\_secret\_parameter\_name) | n/a | | [contact\_details\_table\_name](#output\_contact\_details\_table\_name) | n/a | | [deployment](#output\_deployment) | Deployment details used for post-deployment scripts | | [download\_bucket\_name](#output\_download\_bucket\_name) | n/a | diff --git a/infrastructure/terraform/components/sbx/outputs.tf b/infrastructure/terraform/components/sbx/outputs.tf index 455f228770..7708a0d851 100644 --- a/infrastructure/terraform/components/sbx/outputs.tf +++ b/infrastructure/terraform/components/sbx/outputs.tf @@ -89,3 +89,7 @@ output "contact_details_table_name" { output "templates_quarantine_bucket_key_prefix" { value = module.backend_api.templates_quarantine_bucket_key_prefix } + +output "contact_details_otp_secret_parameter_name" { + value = module.backend_api.contact_details_otp_secret_parameter_name +} diff --git a/infrastructure/terraform/modules/backend-api/README.md b/infrastructure/terraform/modules/backend-api/README.md index 84d15d2697..c91abab92c 100644 --- a/infrastructure/terraform/modules/backend-api/README.md +++ b/infrastructure/terraform/modules/backend-api/README.md @@ -90,12 +90,14 @@ No requirements. | [update\_template\_lambda](#module\_update\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [upload\_docx\_letter\_template\_lambda](#module\_upload\_docx\_letter\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [upload\_letter\_template\_lambda](#module\_upload\_letter\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [verify\_contact\_detail\_lambda](#module\_verify\_contact\_detail\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/4.0.0/terraform-lambda.zip | n/a | ## Outputs | Name | Description | |------|-------------| | [api\_base\_url](#output\_api\_base\_url) | n/a | | [client\_ssm\_path\_prefix](#output\_client\_ssm\_path\_prefix) | n/a | +| [contact\_details\_otp\_secret\_parameter\_name](#output\_contact\_details\_otp\_secret\_parameter\_name) | n/a | | [contact\_details\_table\_name](#output\_contact\_details\_table\_name) | n/a | | [download\_bucket\_name](#output\_download\_bucket\_name) | n/a | | [download\_bucket\_regional\_domain\_name](#output\_download\_bucket\_regional\_domain\_name) | n/a | diff --git a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf index b359411a94..e8e1e56f69 100644 --- a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf +++ b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf @@ -76,6 +76,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" { module.submit_routing_config_lambda.function_arn, module.update_routing_config_lambda.function_arn, module.update_template_lambda.function_arn, + module.verify_contact_detail_lambda.function_arn, ] } } diff --git a/infrastructure/terraform/modules/backend-api/locals.tf b/infrastructure/terraform/modules/backend-api/locals.tf index 053853f600..710b625bc0 100644 --- a/infrastructure/terraform/modules/backend-api/locals.tf +++ b/infrastructure/terraform/modules/backend-api/locals.tf @@ -43,6 +43,7 @@ locals { UPDATE_TEMPLATE_LAMBDA_ARN = module.update_template_lambda.function_arn UPLOAD_DOCX_LETTER_LAMBDA_ARN = module.upload_docx_letter_template_lambda.function_arn UPLOAD_LETTER_LAMBDA_ARN = module.upload_letter_template_lambda.function_arn + VERIFY_CONTACT_DETAIL_LAMBDA_ARN = module.verify_contact_detail_lambda.function_arn }) backend_lambda_environment_variables = { diff --git a/infrastructure/terraform/modules/backend-api/module_verify_contact_detail_lambda.tf b/infrastructure/terraform/modules/backend-api/module_verify_contact_detail_lambda.tf new file mode 100644 index 0000000000..b7b13956e1 --- /dev/null +++ b/infrastructure/terraform/modules/backend-api/module_verify_contact_detail_lambda.tf @@ -0,0 +1,90 @@ +module "verify_contact_detail_lambda" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/4.0.0/terraform-lambda.zip" + + project = var.project + environment = var.environment + component = var.component + aws_account_id = var.aws_account_id + region = var.region + + kms_key_arn = var.kms_key_arn + + function_name = "verify-contact-detail" + + function_module_name = "verify-contact-detail" + handler_function_name = "handler" + description = "API endpoint for adding verifying contact details contact details using an OTP" + + memory = 2048 + timeout = 20 + runtime = "nodejs22.x" + + log_retention_in_days = var.log_retention_in_days + iam_policy_document = { + body = data.aws_iam_policy_document.verify_contact_detail.json + } + + lambda_env_vars = local.backend_lambda_environment_variables + function_s3_bucket = var.function_s3_bucket + function_code_base_path = local.lambdas_dir + function_code_dir = "backend-api/dist/verify-contact-detail" + + send_to_firehose = var.send_to_firehose + log_destination_arn = var.log_destination_arn + log_subscription_role_arn = var.log_subscription_role_arn +} + +data "aws_iam_policy_document" "verify_contact_detail" { + statement { + sid = "AllowKMSAccess" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:Encrypt", + "kms:GenerateDataKey*", + "kms:ReEncrypt*", + ] + + resources = [ + var.kms_key_arn + ] + } + statement { + sid = "AllowContactDetailsRead" + effect = "Allow" + + actions = [ + "dynamodb:Query" + ] + + resources = [ + "${aws_dynamodb_table.contact_details.arn}/index/ById", + ] + } + + statement { + sid = "AllowContactDetailsWrite" + effect = "Allow" + + actions = [ + "dynamodb:UpdateItem", + ] + + resources = [aws_dynamodb_table.contact_details.arn] + } + + statement { + sid = "AllowOtpSecretRead" + effect = "Allow" + + actions = [ + "ssm:GetParameter", + ] + + resources = [ + aws_ssm_parameter.contact_details_otp_secret.arn, + ] + } +} diff --git a/infrastructure/terraform/modules/backend-api/outputs.tf b/infrastructure/terraform/modules/backend-api/outputs.tf index 2456e97306..3e80479bd2 100644 --- a/infrastructure/terraform/modules/backend-api/outputs.tf +++ b/infrastructure/terraform/modules/backend-api/outputs.tf @@ -61,3 +61,7 @@ output "contact_details_table_name" { output "templates_quarantine_bucket_key_prefix" { value = local.csi } + +output "contact_details_otp_secret_parameter_name" { + value = aws_ssm_parameter.contact_details_otp_secret.name +} diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index c1af6daf83..3f3df4cf97 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -473,31 +473,31 @@ ], "type": "object" }, - "ContactDetail": { - "allOf": [ - { - "$ref": "#/components/schemas/ContactDetailInput" + "ContactDetailDTO": { + "properties": { + "id": { + "type": "string" }, - { - "properties": { - "id": { - "type": "string" - }, - "rawValue": { - "type": "string" - }, - "status": { - "$ref": "#/components/schemas/ContactDetailStatus" - } - }, - "required": [ - "id", - "rawValue", - "status" - ], - "type": "object" + "status": { + "$ref": "#/components/schemas/ContactDetailStatus" + }, + "type": { + "$ref": "#/components/schemas/ContactDetailType" + }, + "rawValue": { + "type": "string" + }, + "value": { + "type": "string" } - ] + }, + "required": [ + "type", + "value", + "id", + "status" + ], + "type": "object" }, "ContactDetailInput": { "properties": { @@ -542,7 +542,7 @@ "ContactDetailSuccess": { "properties": { "data": { - "$ref": "#/components/schemas/ContactDetail" + "$ref": "#/components/schemas/ContactDetailDTO" }, "statusCode": { "type": "integer" @@ -558,7 +558,7 @@ "properties": { "data": { "items": { - "$ref": "#/components/schemas/ContactDetail" + "$ref": "#/components/schemas/ContactDetailDTO" }, "type": "array" }, @@ -1580,6 +1580,17 @@ ], "type": "object" }, + "VerifyContactDetailInput": { + "properties": { + "otp": { + "type": "string" + } + }, + "required": [ + "otp" + ], + "type": "object" + }, "VersionedFileDetails": { "properties": { "currentVersion": { @@ -3531,6 +3542,80 @@ "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${LIST_TEMPLATES_LAMBDA_ARN}/invocations" } } + }, + "/v1/verify-contact-detail/{contactDetailId}": { + "post": { + "description": "Verify contact detail", + "parameters": [ + { + "description": "ID of the contact detail", + "in": "path", + "name": "contactDetailId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyContactDetailInput" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactDetailSuccess" + } + } + }, + "description": "200 response", + "headers": { + "Content-Type": { + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Failure" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authorizer": [] + } + ], + "summary": "Add new unverified contact details and send an OTP for verification", + "x-amazon-apigateway-integration": { + "contentHandling": "CONVERT_TO_TEXT", + "credentials": "${APIG_EXECUTION_ROLE_ARN}", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "responses": { + ".*": { + "statusCode": "200" + } + }, + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${VERIFY_CONTACT_DETAIL_LAMBDA_ARN}/invocations" + } + } } } } diff --git a/lambdas/backend-api/build.sh b/lambdas/backend-api/build.sh index b79bc80340..af3e550bcd 100755 --- a/lambdas/backend-api/build.sh +++ b/lambdas/backend-api/build.sh @@ -46,6 +46,7 @@ npx esbuild \ src/update.ts \ src/upload-docx-letter.ts \ src/upload-letter.ts \ - src/validate-letter-template-files.ts + src/validate-letter-template-files.ts \ + src/verify-contact-detail.ts cp -r ../../utils/utils/src/email-templates ./dist/submit diff --git a/lambdas/backend-api/src/__tests__/api/create-contact-details.test.ts b/lambdas/backend-api/src/__tests__/api/create-contact-details.test.ts index c8fba15d23..6b83a0f393 100644 --- a/lambdas/backend-api/src/__tests__/api/create-contact-details.test.ts +++ b/lambdas/backend-api/src/__tests__/api/create-contact-details.test.ts @@ -1,9 +1,7 @@ import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; import { mock } from 'jest-mock-extended'; -import type { - ContactDetail, - ContactDetailInput, -} from 'nhs-notify-web-template-management-types'; +import type { ContactDetailInput } from 'nhs-notify-web-template-management-types'; +import { DatabaseContactDetail } from 'nhs-notify-backend-client/schemas'; import { createHandler } from '@backend-api/api/create-contact-details'; import type { ContactDetailsClient } from '@backend-api/app/contact-details-client'; @@ -137,11 +135,14 @@ describe('Create Contact Details Handler', () => { value: '07890123456', }; - const response: ContactDetail = { + const response: DatabaseContactDetail = { ...input, id: 'id', rawValue: input.value, status: 'PENDING_VERIFICATION', + clientId: 'client-id', + otpHash: 'otp-hash', + rawValue: 'raw-value', }; mocks.contactDetailsClient.create.mockResolvedValueOnce({ diff --git a/lambdas/backend-api/src/__tests__/api/get-contact-details.test.ts b/lambdas/backend-api/src/__tests__/api/get-contact-details.test.ts index e2875b69a9..2d8644233f 100644 --- a/lambdas/backend-api/src/__tests__/api/get-contact-details.test.ts +++ b/lambdas/backend-api/src/__tests__/api/get-contact-details.test.ts @@ -1,6 +1,6 @@ import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; import { mock } from 'jest-mock-extended'; -import type { ContactDetail } from 'nhs-notify-web-template-management-types'; +import type { ContactDetailDto } from 'nhs-notify-web-template-management-types'; import { createHandler } from '@backend-api/api/get-contact-details'; import type { ContactDetailsClient } from '@backend-api/app/contact-details-client'; @@ -105,7 +105,7 @@ describe('Get Contact Details Handler', () => { test('should return the contact details', async () => { const { handler, mocks } = setup(); - const response: ContactDetail = { + const response: ContactDetailDto = { type: 'SMS', value: '07890123456', rawValue: '07890123456', diff --git a/lambdas/backend-api/src/__tests__/api/list-contact-details.test.ts b/lambdas/backend-api/src/__tests__/api/list-contact-details.test.ts index 9705af02b8..ba7dab89aa 100644 --- a/lambdas/backend-api/src/__tests__/api/list-contact-details.test.ts +++ b/lambdas/backend-api/src/__tests__/api/list-contact-details.test.ts @@ -1,6 +1,6 @@ import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; import { mock } from 'jest-mock-extended'; -import type { ContactDetail } from 'nhs-notify-web-template-management-types'; +import type { ContactDetailDto } from 'nhs-notify-web-template-management-types'; import { createHandler } from '@backend-api/api/list-contact-details'; import type { ContactDetailsClient } from '@backend-api/app/contact-details-client'; @@ -96,7 +96,7 @@ describe('List Contact Details Handler', () => { test('should return list of contact details', async () => { const { handler, mocks } = setup(); - const response: ContactDetail[] = [ + const response: ContactDetailDto[] = [ { type: 'SMS', value: '07890123456', diff --git a/lambdas/backend-api/src/__tests__/api/verify-contact-detail.test.ts b/lambdas/backend-api/src/__tests__/api/verify-contact-detail.test.ts new file mode 100644 index 0000000000..f1196bd82b --- /dev/null +++ b/lambdas/backend-api/src/__tests__/api/verify-contact-detail.test.ts @@ -0,0 +1,171 @@ +import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { mock } from 'jest-mock-extended'; +import type { ContactDetailDto } from 'nhs-notify-web-template-management-types'; +import { createHandler } from '@backend-api/api/verify-contact-detail'; +import type { ContactDetailsClient } from '@backend-api/app/contact-details-client'; + +function setup() { + const contactDetailsClient = mock(); + const mocks = { contactDetailsClient }; + const handler = createHandler(mocks); + + return { handler, mocks }; +} + +describe('Verify Contact Detail Handler', () => { + beforeEach(jest.resetAllMocks); + + test.each([ + ['undefined', undefined], + ['missing user', { clientId: 'client-id', internalUserId: undefined }], + ['missing client', { clientId: undefined, internalUserId: 'user-1234' }], + ])( + 'should return 500 - Invalid request when requestContext is %s', + async (_, ctx) => { + const { handler, mocks } = setup(); + + const event = mock({ + requestContext: { authorizer: ctx }, + body: JSON.stringify({}), + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + statusCode: 500, + technicalMessage: 'Invalid request', + }), + }); + + expect(mocks.contactDetailsClient.verify).not.toHaveBeenCalled(); + } + ); + + test('should return 400 - Invalid request when no body', async () => { + const { handler, mocks } = setup(); + + mocks.contactDetailsClient.verify.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 400, + description: 'Validation failed', + details: { + otp: 'Invalid input: expected string, received undefined', + }, + }, + }, + data: undefined, + }); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + }, + pathParameters: { contactDetailId: 'cd-1' }, + body: undefined, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Validation failed', + details: { + otp: 'Invalid input: expected string, received undefined', + }, + }), + }); + + expect(mocks.contactDetailsClient.verify).toHaveBeenCalledWith( + 'cd-1', + {}, + { internalUserId: 'user-1234', clientId: 'nhs-notify-client-id' } + ); + }); + + test('should return error when verify request fails', async () => { + const { handler, mocks } = setup(); + + mocks.contactDetailsClient.verify.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 500, + description: 'Internal server error', + }, + }, + }); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'nhs-notify-client-id', + }, + }, + pathParameters: { contactDetailId: 'cd-1' }, + body: JSON.stringify({ otp: '123456' }), + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + statusCode: 500, + technicalMessage: 'Internal server error', + }), + }); + + expect(mocks.contactDetailsClient.verify).toHaveBeenCalledWith( + 'cd-1', + { otp: '123456' }, + { internalUserId: 'user-1234', clientId: 'nhs-notify-client-id' } + ); + }); + + test('should return verified contact detail', async () => { + const { handler, mocks } = setup(); + + const response: ContactDetailDto = { + id: 'cd-1', + type: 'EMAIL', + value: 'test@nhs.net', + status: 'VERIFIED', + }; + + mocks.contactDetailsClient.verify.mockResolvedValueOnce({ + data: response, + }); + + const event = mock({ + requestContext: { + authorizer: { + internalUserId: 'user-1234', + clientId: 'notify-client-id', + }, + }, + pathParameters: { contactDetailId: 'cd-1' }, + body: JSON.stringify({ otp: '123456' }), + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 200, + body: JSON.stringify({ statusCode: 200, data: response }), + }); + + expect(mocks.contactDetailsClient.verify).toHaveBeenCalledWith( + 'cd-1', + { otp: '123456' }, + { internalUserId: 'user-1234', clientId: 'notify-client-id' } + ); + }); +}); diff --git a/lambdas/backend-api/src/__tests__/app/contact-details-client.test.ts b/lambdas/backend-api/src/__tests__/app/contact-details-client.test.ts index f455775828..31fa8e84a5 100644 --- a/lambdas/backend-api/src/__tests__/app/contact-details-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/contact-details-client.test.ts @@ -1,10 +1,11 @@ import { mock } from 'jest-mock-extended'; import type { FailureResult } from 'nhs-notify-backend-client/types'; -import type { ContactDetail } from 'nhs-notify-web-template-management-types'; +import type { ContactDetailDto } from 'nhs-notify-web-template-management-types'; import type { User } from 'nhs-notify-web-template-management-utils'; import { ContactDetailsClient } from '@backend-api/app/contact-details-client'; import type { ContactDetailsRepository } from '@backend-api/infra/contact-details-repository'; import type { OtpService } from '@backend-api/infra/otp-service'; +import { DatabaseContactDetail } from 'nhs-notify-backend-client/schemas'; const USER: User = { internalUserId: 'user-id', @@ -15,15 +16,15 @@ const OTP = '1234'; function setup() { const contactDetailsRepo = mock(); - contactDetailsRepo.putContactDetail.mockImplementation((input, _, user) => - Promise.resolve({ - data: { - ...input, - clientId: user.clientId, - id: 'contact-details-id', - status: 'PENDING_VERIFICATION', - }, - }) + contactDetailsRepo.putContactDetail.mockImplementation( + ({ rawValue, ...input }) => + Promise.resolve({ + data: { + ...input, + id: 'contact-details-id', + status: 'PENDING_VERIFICATION', + }, + }) ); contactDetailsRepo.list.mockResolvedValue({ data: [], @@ -56,7 +57,6 @@ describe('ContactDetailsClient', () => { rawValue: ' TEST@nhs.net ', }, expected: { - clientId: USER.clientId, id: 'contact-details-id', type: 'EMAIL', value: 'test@nhs.net', @@ -72,7 +72,6 @@ describe('ContactDetailsClient', () => { rawValue: '07890 123 456', }, expected: { - clientId: USER.clientId, id: 'contact-details-id', type: 'SMS', value: '+447890123456', @@ -186,6 +185,186 @@ describe('ContactDetailsClient', () => { }); }); + describe('verify', () => { + it('verifies a contact detail with valid OTP', async () => { + const { client, mocks } = setup(); + + const contactDetailDto: ContactDetailDto = { + id: 'contact-detail-id', + status: 'PENDING_VERIFICATION' as const, + type: 'EMAIL' as const, + value: 'test@nhs.net', + }; + + const contactDetail: DatabaseContactDetail = { + ...contactDetailDto, + clientId: USER.clientId, + otpHash: 'otp-hash', + rawValue: 'test@nhs.net', + }; + + mocks.contactDetailsRepo.getById.mockResolvedValueOnce({ + data: contactDetail, + }); + + mocks.contactDetailsRepo.hashOtp.mockResolvedValueOnce('otp-hash'); + + const updatedContactDetail: ContactDetailDto = { + ...contactDetailDto, + status: 'VERIFIED' as const, + }; + + mocks.contactDetailsRepo.updateToVerified.mockResolvedValueOnce({ + data: updatedContactDetail, + }); + + const result = await client.verify( + 'contact-detail-id', + { otp: '1234' }, + USER + ); + + expect(result).toEqual({ + data: { + id: 'contact-detail-id', + status: 'VERIFIED', + type: 'EMAIL', + value: 'test@nhs.net', + }, + }); + + expect(mocks.contactDetailsRepo.getById).toHaveBeenCalledWith( + 'contact-detail-id', + USER, + true + ); + + expect(mocks.contactDetailsRepo.hashOtp).toHaveBeenCalledWith( + contactDetail, + '1234' + ); + + expect(mocks.contactDetailsRepo.updateToVerified).toHaveBeenCalledWith( + contactDetail, + { + clientId: 'client-id', + internalUserId: USER.internalUserId, + } + ); + }); + + it('returns payload validation error result', async () => { + const { client } = setup(); + + const result = await client.verify('contat-detail-id', {}, USER); + + expect(result.data).toBeUndefined(); + expect(result.error?.errorMeta.code).toBe(400); + expect(result.error?.errorMeta.description).toBe( + 'Request failed validation' + ); + }); + + it('returns error result when getById fails', async () => { + const { client, mocks } = setup(); + + const error: FailureResult = { + error: { + errorMeta: { + code: 404, + description: 'Contact details not found.', + }, + }, + }; + + mocks.contactDetailsRepo.getById.mockResolvedValueOnce(error); + + const result = await client.verify( + 'contact-detail-id', + { otp: '1234' }, + USER + ); + + expect(result).toBe(error); + }); + + it('returns failure when OTP does not match', async () => { + const { client, mocks } = setup(); + + const contactDetail = { + id: 'contact-detail-id', + clientId: USER.clientId, + status: 'PENDING_VERIFICATION' as const, + type: 'SMS' as const, + value: '+447890123456', + otpHash: 'stored-hash', + rawValue: '07890123456', + }; + + mocks.contactDetailsRepo.getById.mockResolvedValueOnce({ + data: contactDetail, + }); + + mocks.contactDetailsRepo.hashOtp.mockResolvedValueOnce('different-hash'); + + const result = await client.verify( + 'contact-detail-id', + { otp: 'wrong-otp' }, + USER + ); + + expect(result).toEqual({ + error: { + errorMeta: { + code: 400, + description: 'Invalid contact detail verification', + }, + }, + }); + + expect(mocks.contactDetailsRepo.updateToVerified).not.toHaveBeenCalled(); + }); + + it('returns error result when updateToVerified fails', async () => { + const { client, mocks } = setup(); + + const contactDetail = { + id: 'contact-detail-id', + clientId: USER.clientId, + status: 'PENDING_VERIFICATION' as const, + type: 'EMAIL' as const, + value: 'test@nhs.net', + otpHash: 'matching-hash', + rawValue: 'test@nhs.net', + }; + + mocks.contactDetailsRepo.getById.mockResolvedValueOnce({ + data: contactDetail, + }); + + mocks.contactDetailsRepo.hashOtp.mockResolvedValueOnce('matching-hash'); + + const error: FailureResult = { + error: { + errorMeta: { + code: 500, + description: 'Something went wrong', + }, + }, + }; + + mocks.contactDetailsRepo.updateToVerified.mockResolvedValueOnce(error); + + const result = await client.verify( + 'contact-detail-id', + { otp: '1234' }, + USER + ); + + expect(result).toBe(error); + }); + }); + describe('list', () => { it.each([ { @@ -218,7 +397,7 @@ describe('ContactDetailsClient', () => { async ({ input, expected }) => { const { client, mocks } = setup(); - const contactDetail: ContactDetail = { + const contactDetail: ContactDetailDto = { id: 'contact-detail-id', status: 'VERIFIED', type: 'SMS', @@ -279,7 +458,7 @@ describe('ContactDetailsClient', () => { it('returns retrieved contact details', async () => { const { client, mocks } = setup(); - const contactDetail: ContactDetail = { + const contactDetail: ContactDetailDto = { id: 'contact-detail-id', status: 'VERIFIED', type: 'SMS', @@ -299,7 +478,8 @@ describe('ContactDetailsClient', () => { expect(mocks.contactDetailsRepo.getById).toHaveBeenCalledWith( contactDetail.id, - USER + USER, + false ); }); @@ -321,7 +501,11 @@ describe('ContactDetailsClient', () => { expect(result).toBe(error); - expect(mocks.contactDetailsRepo.getById).toHaveBeenCalledWith('id', USER); + expect(mocks.contactDetailsRepo.getById).toHaveBeenCalledWith( + 'id', + USER, + false + ); }); }); }); diff --git a/lambdas/backend-api/src/__tests__/domain/hash-contact-details-otp.test.ts b/lambdas/backend-api/src/__tests__/domain/hash-contact-details-otp.test.ts index c5cfc6f31c..e41b6c4ec6 100644 --- a/lambdas/backend-api/src/__tests__/domain/hash-contact-details-otp.test.ts +++ b/lambdas/backend-api/src/__tests__/domain/hash-contact-details-otp.test.ts @@ -1,8 +1,8 @@ -import type { ContactDetail } from 'nhs-notify-web-template-management-types'; +import type { ContactDetailDto } from 'nhs-notify-web-template-management-types'; import { hashContactDetailsOtp } from '../../domain/hash-contact-details-otp'; describe('hashContactDetailsOtp', () => { - const contactDetail: ContactDetail = { + const contactDetail: ContactDetailDto = { id: 'contact-123', value: 'user@example.com', rawValue: 'User@Example.COM', @@ -28,11 +28,11 @@ describe('hashContactDetailsOtp', () => { }); it('should produce different hashes for different contact detail ids', () => { - const contactDetail1: ContactDetail = { + const contactDetail1: ContactDetailDto = { ...contactDetail, id: 'contact-abc', }; - const contactDetail2: ContactDetail = { + const contactDetail2: ContactDetailDto = { ...contactDetail, id: 'contact-xyz', }; @@ -44,11 +44,11 @@ describe('hashContactDetailsOtp', () => { }); it('should produce different hashes for different contact detail values', () => { - const contactDetail1: ContactDetail = { + const contactDetail1: ContactDetailDto = { ...contactDetail, value: 'user1@example.com', }; - const contactDetail2: ContactDetail = { + const contactDetail2: ContactDetailDto = { ...contactDetail, value: 'user2@example.com', }; @@ -74,12 +74,12 @@ describe('hashContactDetailsOtp', () => { }); it('should use length-prefixed format to avoid collisions', () => { - const contactDetail1: ContactDetail = { + const contactDetail1: ContactDetailDto = { ...contactDetail, id: 'abc', value: 'de', }; - const contactDetail2: ContactDetail = { + const contactDetail2: ContactDetailDto = { ...contactDetail, id: 'ab', value: 'cdef', diff --git a/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts b/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts index 3c14560105..9a262983a4 100644 --- a/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts @@ -5,6 +5,7 @@ import { DynamoDBDocumentClient, PutCommand, QueryCommand, + UpdateCommand, } from '@aws-sdk/lib-dynamodb'; import { mockClient } from 'aws-sdk-client-mock'; import 'aws-sdk-client-mock-jest'; @@ -109,7 +110,7 @@ describe('ContactDetailsRepository', () => { }); expect(hashContactDetailsOtp).toHaveBeenCalledWith( - result.data, + { id: RANDOM_UUID, value: input.value }, OTP, SECRET_VALUE ); @@ -286,6 +287,109 @@ describe('ContactDetailsRepository', () => { }); }); + describe('updateToVerified', () => { + const contactDetail = { + id: 'contact-detail-id', + clientId: USER.clientId, + status: 'PENDING_VERIFICATION' as const, + type: 'EMAIL' as const, + value: 'test@nhs.net', + rawValue: 'test@nhs.net', + otpHash: FAKE_HASH, + }; + const expectedKey = { + owner: `INTERNAL_USER#${USER.internalUserId}`, + contactDetailKey: 'EMAIL#test@nhs.net', + }; + + it('sends an UpdateCommand and returns the verified contact detail', async () => { + const { repo, mocks } = setup(); + + mocks.dynamodb.on(UpdateCommand).resolves({ + Attributes: { + id: contactDetail.id, + status: 'VERIFIED', + type: 'EMAIL', + value: 'test@nhs.net', + }, + }); + + const result = await repo.updateToVerified(contactDetail, USER); + + expect(result).toEqual({ + data: { + id: contactDetail.id, + status: 'VERIFIED', + type: 'EMAIL', + value: 'test@nhs.net', + }, + }); + + expect(mocks.dynamodb).toHaveReceivedCommandWith(UpdateCommand, { + TableName: TABLE_NAME, + Key: expectedKey, + UpdateExpression: 'SET #status = :verifiedStatus REMOVE #ttl, otpHash', + ExpressionAttributeNames: { + '#status': 'status', + '#ttl': 'ttl', + }, + ExpressionAttributeValues: { + ':verifiedStatus': 'VERIFIED', + ':pendingVerificationStatus': 'PENDING_VERIFICATION', + ':id': contactDetail.id, + }, + ConditionExpression: + '#status = :pendingVerificationStatus AND id = :id', + ReturnValues: 'ALL_NEW', + }); + }); + + it('returns internal error result if Attributes fail validation', async () => { + const { repo, mocks } = setup(); + + mocks.dynamodb.on(UpdateCommand).resolves({ + Attributes: { invalid: 'data' }, + }); + + const result = await repo.updateToVerified(contactDetail, USER); + + expect(result.data).toBeUndefined(); + expect(result.error?.errorMeta.code).toBe(500); + expect(result.error?.errorMeta.description).toBe('Internal server error'); + }); + + it('returns conflict error when update fails', async () => { + const { repo, mocks } = setup(); + + mocks.dynamodb.on(UpdateCommand).rejects( + new ConditionalCheckFailedException({ + message: 'Condition not met', + $metadata: {}, + }) + ); + + const result = await repo.updateToVerified(contactDetail, USER); + + expect(result.data).toBeUndefined(); + expect(result.error?.errorMeta.code).toBe(409); + expect(result.error?.errorMeta.description).toBe('Conflict'); + }); + + it('returns internal error result if update fails with unknown error', async () => { + const { repo, mocks } = setup(); + + mocks.dynamodb.on(UpdateCommand).rejects(new Error('DynamoDB failure')); + + const result = await repo.updateToVerified(contactDetail, USER); + + expect(result.data).toBeUndefined(); + expect(result.error?.errorMeta.code).toBe(500); + expect(result.error?.errorMeta.description).toBe( + 'Failed to verify contact detail' + ); + }); + }); + describe('list', () => { it('queries dynamodb and returns a list of parsed contact details', async () => { const { repo, mocks } = setup(); @@ -658,7 +762,8 @@ describe('ContactDetailsRepository', () => { const result = await repo.getById( '8a7cbed5-ad44-4b8a-ae86-5e52c9caf4bb', - USER + USER, + false ); expect(result.data).toEqual({ @@ -684,6 +789,61 @@ describe('ContactDetailsRepository', () => { }); }); + it('queries dynamodb by id and returns the parsed contact details with returnFullDatabaseEntry true', async () => { + const { repo, mocks } = setup(); + + mocks.dynamodb.on(QueryCommand).resolves({ + Items: [ + { + owner: `INTERNAL_USER#${USER.internalUserId}`, + contactDetailKey: `SMS#+447890123456`, + createdAt: NOW.toISOString(), + createdBy: `INTERNAL_USER#${USER.internalUserId}`, + clientId: USER.clientId, + id: '8a7cbed5-ad44-4b8a-ae86-5e52c9caf4bb', + otpHash: FAKE_HASH, + rawValue: '07890 123 456', + status: 'PENDING_VERIFICATION', + ttl: EXPECTED_TTL, + type: 'SMS', + updatedAt: NOW.toISOString(), + updatedBy: `INTERNAL_USER#${USER.internalUserId}`, + value: '+447890123456', + }, + ], + }); + + const result = await repo.getById( + '8a7cbed5-ad44-4b8a-ae86-5e52c9caf4bb', + USER, + true + ); + + expect(result.data).toEqual({ + id: '8a7cbed5-ad44-4b8a-ae86-5e52c9caf4bb', + type: 'SMS', + status: 'PENDING_VERIFICATION', + value: '+447890123456', + clientId: USER.clientId, + otpHash: FAKE_HASH, + rawValue: '07890 123 456', + }); + + expect(mocks.dynamodb).toHaveReceivedCommandWith(QueryCommand, { + TableName: TABLE_NAME, + IndexName: 'ById', + KeyConditionExpression: '#id = :id AND #owner = :owner', + ExpressionAttributeNames: { + '#id': 'id', + '#owner': 'owner', + }, + ExpressionAttributeValues: { + ':id': '8a7cbed5-ad44-4b8a-ae86-5e52c9caf4bb', + ':owner': `INTERNAL_USER#${USER.internalUserId}`, + }, + }); + }); + it('returns not found error result if no items are returned in the query', async () => { const { repo, mocks } = setup(); @@ -691,7 +851,8 @@ describe('ContactDetailsRepository', () => { const result = await repo.getById( '8a7cbed5-ad44-4b8a-ae86-5e52c9caf4bb', - USER + USER, + false, ); expect(result.data).toBeUndefined(); @@ -708,7 +869,8 @@ describe('ContactDetailsRepository', () => { const result = await repo.getById( '8a7cbed5-ad44-4b8a-ae86-5e52c9caf4bb', - USER + USER, + false, ); expect(result.data).toBeUndefined(); diff --git a/lambdas/backend-api/src/api/responses.ts b/lambdas/backend-api/src/api/responses.ts index 42266d26da..4ce70aaef7 100644 --- a/lambdas/backend-api/src/api/responses.ts +++ b/lambdas/backend-api/src/api/responses.ts @@ -4,15 +4,15 @@ import type { RoutingConfig, RoutingConfigReference, LetterVariant, - ContactDetail, + ContactDetailDto, } from 'nhs-notify-web-template-management-types'; type Count = { count: number }; export const apiSuccess = < T extends - | ContactDetail - | ContactDetail[] + | ContactDetailDto + | ContactDetailDto[] | Count | LetterVariant | LetterVariant[] diff --git a/lambdas/backend-api/src/api/verify-contact-detail.ts b/lambdas/backend-api/src/api/verify-contact-detail.ts new file mode 100644 index 0000000000..58ea9c5368 --- /dev/null +++ b/lambdas/backend-api/src/api/verify-contact-detail.ts @@ -0,0 +1,54 @@ +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import { logger } from 'nhs-notify-web-template-management-utils/logger'; +import type { ContactDetailsClient } from '@backend-api/app/contact-details-client'; +import { apiFailure, apiSuccess } from '@backend-api/api/responses'; +import { ErrorCase } from 'nhs-notify-backend-client/types'; + +type Dependencies = { + contactDetailsClient: ContactDetailsClient; +}; + +export function createHandler({ + contactDetailsClient, +}: Dependencies): APIGatewayProxyHandler { + return async function handler(event) { + const { internalUserId, clientId } = event.requestContext.authorizer ?? {}; + + const contactDetailId = event.pathParameters?.contactDetailId; + + const user = { + clientId, + internalUserId, + }; + + const log = logger.child({ ...user, contactDetailId }); + + if (!clientId || !internalUserId || !contactDetailId) { + log.error('Invalid event received from API Gateway'); + + return apiFailure(ErrorCase.INTERNAL, 'Invalid request'); + } + + const payload = JSON.parse(event.body || '{}'); + + const { data, error } = await contactDetailsClient.verify( + contactDetailId, + payload, + user + ); + + if (error) { + log + .child(error.errorMeta) + .error('Failed to verify contact detail', error.actualError); + + return apiFailure( + error.errorMeta.code, + error.errorMeta.description, + error.errorMeta.details + ); + } + + return apiSuccess(200, data); + }; +} diff --git a/lambdas/backend-api/src/app/contact-details-client.ts b/lambdas/backend-api/src/app/contact-details-client.ts index d479de714d..6529aeb090 100644 --- a/lambdas/backend-api/src/app/contact-details-client.ts +++ b/lambdas/backend-api/src/app/contact-details-client.ts @@ -2,13 +2,15 @@ import { $ContactDetailFilters, $ContactDetailInputNormalized, ContactDetailFilters, + $VerifyContactDetailInput, } from 'nhs-notify-backend-client/schemas'; -import type { Result } from 'nhs-notify-backend-client/types'; -import type { ContactDetail } from 'nhs-notify-web-template-management-types'; +import type { ContactDetailDto } from 'nhs-notify-web-template-management-types'; +import { ErrorCase, type Result } from 'nhs-notify-backend-client/types'; import type { User } from 'nhs-notify-web-template-management-utils'; import type { ContactDetailsRepository } from '@backend-api/infra/contact-details-repository'; import type { OtpService } from '@backend-api/infra/otp-service'; -import { validate } from '@backend-api/utils'; +import { failure, validate } from '@backend-api/utils'; +import { success } from '@backend-api/utils'; export class ContactDetailsClient { constructor( @@ -16,7 +18,10 @@ export class ContactDetailsClient { private otpService: OtpService ) {} - async create(payload: unknown, user: User): Promise> { + async create( + payload: unknown, + user: User + ): Promise> { const validation = await validate($ContactDetailInputNormalized, payload); if (validation.error) return validation; @@ -40,7 +45,51 @@ export class ContactDetailsClient { return contactDetail; } - async list(user: User, filters?: unknown): Promise> { + async verify( + contactDetailId: string, + payload: unknown, + user: User + ): Promise> { + const validation = await validate($VerifyContactDetailInput, payload); + + if (validation.error) return validation; + + const { otp } = validation.data; + + const contactDetail = await this.contactDetailsRepo.getById( + contactDetailId, + user, + true + ); + + if (contactDetail.error) return contactDetail; + + const otpHash = await this.contactDetailsRepo.hashOtp( + contactDetail.data, + otp + ); + + if (otpHash !== contactDetail.data.otpHash) { + return failure( + ErrorCase.INVALID_VERIFICATION, + 'Invalid contact detail verification' + ); + } + + const updatedContactDetail = await this.contactDetailsRepo.updateToVerified( + contactDetail.data, + user + ); + + if (updatedContactDetail.error) return updatedContactDetail; + + return success(updatedContactDetail.data); + } + + async list( + user: User, + filters?: unknown + ): Promise> { let parsedFilters: ContactDetailFilters = {}; if (filters) { @@ -56,7 +105,7 @@ export class ContactDetailsClient { return await this.contactDetailsRepo.list(user, parsedFilters); } - async getById(id: string, user: User): Promise> { - return await this.contactDetailsRepo.getById(id, user); + async getById(id: string, user: User): Promise> { + return await this.contactDetailsRepo.getById(id, user, false); } } diff --git a/lambdas/backend-api/src/domain/hash-contact-details-otp.ts b/lambdas/backend-api/src/domain/hash-contact-details-otp.ts index a2f3c52be9..74943c6410 100644 --- a/lambdas/backend-api/src/domain/hash-contact-details-otp.ts +++ b/lambdas/backend-api/src/domain/hash-contact-details-otp.ts @@ -1,5 +1,5 @@ import { createHmac, type Hmac } from 'node:crypto'; -import type { ContactDetail } from 'nhs-notify-web-template-management-types'; +import type { ContactDetailDto } from 'nhs-notify-web-template-management-types'; function updateWithLengthPrefix(hmac: Hmac, value: string): void { hmac.update(`${value.length}:`); @@ -7,7 +7,7 @@ function updateWithLengthPrefix(hmac: Hmac, value: string): void { } export function hashContactDetailsOtp( - details: ContactDetail, + details: Pick, otp: string, secret: string ): string { diff --git a/lambdas/backend-api/src/infra/contact-details-repository.ts b/lambdas/backend-api/src/infra/contact-details-repository.ts index f327513942..f6f04945e6 100644 --- a/lambdas/backend-api/src/infra/contact-details-repository.ts +++ b/lambdas/backend-api/src/infra/contact-details-repository.ts @@ -4,17 +4,20 @@ import { GetParameterCommand, type SSMClient } from '@aws-sdk/client-ssm'; import { PutCommand, QueryCommand, + UpdateCommand, type QueryCommandInput, type QueryCommandOutput, type DynamoDBDocumentClient, } from '@aws-sdk/lib-dynamodb'; import { - $ContactDetail, + $ContactDetailDto, + $DatabaseContactDetail, type ContactDetailFilters, + DatabaseContactDetail, } from 'nhs-notify-backend-client/schemas'; import { ErrorCase } from 'nhs-notify-backend-client/types'; import type { - ContactDetail, + ContactDetailDto, ContactDetailInputNormalized, ContactDetailStatus, } from 'nhs-notify-web-template-management-types'; @@ -33,31 +36,84 @@ export class ContactDetailsRepository { private otpSecretPath: string ) {} + async updateToVerified( + contactDetail: ContactDetailDto, + user: User + ): Promise> { + try { + const { Attributes } = await this.dynamodb.send( + new UpdateCommand({ + TableName: this.tableName, + Key: this.getKey(contactDetail, user), + UpdateExpression: + 'SET #status = :verifiedStatus REMOVE #ttl, otpHash', + ExpressionAttributeNames: { + '#status': 'status', + '#ttl': 'ttl', + }, + ExpressionAttributeValues: { + ':verifiedStatus': 'VERIFIED', + ':pendingVerificationStatus': 'PENDING_VERIFICATION', + ':id': contactDetail.id, + }, + ConditionExpression: + '#status = :pendingVerificationStatus AND id = :id', + ReturnValues: 'ALL_NEW', + }) + ); + + const validatedContactDetail = $ContactDetailDto.safeParse(Attributes); + + if (!validatedContactDetail.success) { + return failure(ErrorCase.INTERNAL, 'Internal server error'); + } + + return success(validatedContactDetail.data); + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + return failure(ErrorCase.CONFLICT, 'Conflict'); + } + + return failure( + ErrorCase.INTERNAL, + 'Failed to verify contact detail', + error + ); + } + } + async putContactDetail( details: ContactDetailInputNormalized, otp: string, user: User - ): Promise> { - const dto: ContactDetail = { - id: randomUUID(), - status: 'PENDING_VERIFICATION', - type: details.type, - value: details.value, - rawValue: details.rawValue, - }; + ): Promise> { + const contactDetailId = randomUUID(); const now = new Date(); try { + const otpHash = await this.hashOtp( + { id: contactDetailId, value: details.value }, + otp + ); + + const contactDetail: DatabaseContactDetail = { + clientId: user.clientId, + id: contactDetailId, + status: 'PENDING_VERIFICATION', + type: details.type, + value: details.value, + otpHash, + rawValue: details.rawValue, + }; + await this.dynamodb.send( new PutCommand({ TableName: this.tableName, Item: { owner: this.getOwnerKey(user), contactDetailKey: this.getContactDetailKey(details), - ...dto, - clientId: user.clientId, - otpHash: await this.hashOtp(dto, otp), + ...contactDetail, ttl: this.getUnverifiedTtl(now), createdAt: now.toISOString(), createdBy: this.getOwnerKey(user), @@ -76,7 +132,7 @@ export class ContactDetailsRepository { }) ); - return success(dto); + return success($ContactDetailDto.parse(contactDetail)); } catch (error) { if (error instanceof ConditionalCheckFailedException) { return failure( @@ -97,7 +153,7 @@ export class ContactDetailsRepository { async list( user: User, filters: ContactDetailFilters = {} - ): Promise> { + ): Promise> { const names: QueryCommandInput['ExpressionAttributeNames'] = { '#owner': 'owner', }; @@ -158,8 +214,19 @@ export class ContactDetailsRepository { async getById( id: string, - user: User - ): Promise> { + user: User, + returnFullDatabaseEntry: true + ): Promise>; + async getById( + id: string, + user: User, + returnFullDatabaseEntry: false + ): Promise>; + async getById( + id: string, + user: User, + returnFullDatabaseEntry: boolean + ): Promise> { try { const { Items = [] } = await this.dynamodb.send( new QueryCommand({ @@ -181,9 +248,11 @@ export class ContactDetailsRepository { return failure(ErrorCase.NOT_FOUND, 'Contact details not found.'); } - const contactDetail = $ContactDetail.parse(Items[0]); + if (returnFullDatabaseEntry) { + return success($DatabaseContactDetail.parse(Items[0])); + } - return success(contactDetail); + return success($ContactDetailDto.parse(Items[0])); } catch (error) { return failure( ErrorCase.INTERNAL, @@ -197,10 +266,19 @@ export class ContactDetailsRepository { return `INTERNAL_USER#${user.internalUserId}`; } - private getContactDetailKey(detail: ContactDetailInputNormalized) { + private getContactDetailKey( + detail: Pick + ) { return `${detail.type}#${detail.value}`; } + private getKey(contactDetail: ContactDetailDto, user: User) { + return { + owner: this.getOwnerKey(user), + contactDetailKey: this.getContactDetailKey(contactDetail), + }; + } + private getUnverifiedTtl(now: Date) { return Math.floor(now.getTime() / 1000) + this.unverifiedTtlSeconds; } @@ -228,16 +306,19 @@ export class ContactDetailsRepository { return secret; } - private async hashOtp(details: ContactDetail, otp: string) { + async hashOtp( + contactDetail: Pick, + otp: string + ) { const secret = await this.getOtpSecret(); - return hashContactDetailsOtp(details, otp, secret); + return hashContactDetailsOtp(contactDetail, otp, secret); } - private parseList(items: unknown[]): ContactDetail[] { + private parseList(items: unknown[]): ContactDetailDto[] { return items .map((item) => { - const { data } = $ContactDetail.safeParse(item); + const { data } = $ContactDetailDto.safeParse(item); return data; }) diff --git a/lambdas/backend-api/src/infra/otp-service.ts b/lambdas/backend-api/src/infra/otp-service.ts index 906d39b69a..1befc3da4d 100644 --- a/lambdas/backend-api/src/infra/otp-service.ts +++ b/lambdas/backend-api/src/infra/otp-service.ts @@ -1,7 +1,7 @@ import { randomInt } from 'node:crypto'; import { type SNSClient, PublishCommand } from '@aws-sdk/client-sns'; import { ErrorCase } from 'nhs-notify-backend-client/types'; -import type { ContactDetail } from 'nhs-notify-web-template-management-types'; +import type { ContactDetailDto } from 'nhs-notify-web-template-management-types'; import type { ContactDetailEventBuilder } from '@backend-api/domain/contact-detail-event-builder'; import { failure, success, type ApplicationResult } from '@backend-api/utils'; @@ -22,7 +22,7 @@ export class OtpService { } async send( - { status, ...detail }: ContactDetail, + { status, ...detail }: ContactDetailDto, otp: string ): Promise> { try { diff --git a/lambdas/backend-api/src/verify-contact-detail.ts b/lambdas/backend-api/src/verify-contact-detail.ts new file mode 100644 index 0000000000..f302f45bdd --- /dev/null +++ b/lambdas/backend-api/src/verify-contact-detail.ts @@ -0,0 +1,4 @@ +import { createHandler } from '@backend-api/api/verify-contact-detail'; +import { contactDetailsContainer } from '@backend-api/container/contact-details'; + +export const handler = createHandler(contactDetailsContainer()); diff --git a/lambdas/backend-client/src/__tests__/schemas/contact-details/__snapshots__/index.test.ts.snap b/lambdas/backend-client/src/__tests__/schemas/contact-details/__snapshots__/index.test.ts.snap index 74ea69a1da..c5a8c4bdc9 100644 --- a/lambdas/backend-client/src/__tests__/schemas/contact-details/__snapshots__/index.test.ts.snap +++ b/lambdas/backend-client/src/__tests__/schemas/contact-details/__snapshots__/index.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`$ContactDetail fails to parse with invalid status 1`] = ` +exports[`$ContactDetailDto fails to parse with invalid status 1`] = ` [ZodError: [ { "code": "invalid_value", @@ -24,7 +24,7 @@ exports[`$ContactDetail fails to parse with invalid status 1`] = ` ]] `; -exports[`$ContactDetail fails to parse with invalid type 1`] = ` +exports[`$ContactDetailDto fails to parse with invalid type 1`] = ` [ZodError: [ { "code": "invalid_value", @@ -87,6 +87,32 @@ exports[`$ContactDetailInputNormalized throws error if type is invalid 1`] = ` ]] `; +exports[`$DatabaseContactDetail throws error if invalid 1`] = ` +[ZodError: [ + { + "expected": "string", + "code": "invalid_type", + "path": [ + "rawValue" + ], + "message": "Invalid input: expected string, received undefined" + } +]] +`; + +exports[`$VerifyContactDetailInput throws error if invalid 1`] = ` +[ZodError: [ + { + "expected": "string", + "code": "invalid_type", + "path": [ + "otp" + ], + "message": "Invalid input: expected string, received undefined" + } +]] +`; + exports[`ContactDetailFilters fails to parse with invalid status 1`] = ` [ZodError: [ { diff --git a/lambdas/backend-client/src/__tests__/schemas/contact-details/index.test.ts b/lambdas/backend-client/src/__tests__/schemas/contact-details/index.test.ts index 7fc83b7a0f..0ec9af94c6 100644 --- a/lambdas/backend-client/src/__tests__/schemas/contact-details/index.test.ts +++ b/lambdas/backend-client/src/__tests__/schemas/contact-details/index.test.ts @@ -1,7 +1,9 @@ import { $ContactDetailInputNormalized, - $ContactDetail, + $DatabaseContactDetail, $ContactDetailFilters, + $ContactDetailDto, + $VerifyContactDetailInput, } from '../../../schemas'; describe('$ContactDetailInputNormalized', () => { @@ -69,7 +71,7 @@ describe('$ContactDetailInputNormalized', () => { }); }); -describe('$ContactDetail', () => { +describe('$ContactDetailDto', () => { it.each([ [ 'verified sms', @@ -92,7 +94,7 @@ describe('$ContactDetail', () => { }, ], ])('parses contact detail data - %s', (_case, data) => { - expect($ContactDetail.parse(data)).toEqual(data); + expect($ContactDetailDto.parse(data)).toEqual(data); }); it.each([ @@ -110,10 +112,47 @@ describe('$ContactDetail', () => { }, ], ])('fails to parse with %s', (_case, data) => { - const result = $ContactDetail.safeParse(data); + const result = $ContactDetailDto.safeParse(data); expect(result.success).toBe(false); + expect(result.error).toMatchSnapshot(); + }); +}); + +describe('$DatabaseContactDetail', () => { + test('parses valid contact detail with rawValue and otpHash', () => { + expect( + $DatabaseContactDetail.parse({ + type: 'EMAIL', + id: 'contact-1', + status: 'VERIFIED', + value: 'test@nhs.net', + clientId: 'client-1', + rawValue: 'TEST@NHS.NET', + otpHash: 'abc123hash', + }) + ).toEqual({ + type: 'EMAIL', + id: 'contact-1', + status: 'VERIFIED', + value: 'test@nhs.net', + clientId: 'client-1', + rawValue: 'TEST@NHS.NET', + otpHash: 'abc123hash', + }); + }); + + test('throws error if invalid', () => { + const result = $DatabaseContactDetail.safeParse({ + type: 'EMAIL', + id: 'contact-1', + status: 'VERIFIED', + value: 'test@nhs.net', + clientId: 'client-1', + otpHash: 'abc123hash', + }); + expect(result.success).toBe(false); expect(result.error).toMatchSnapshot(); }); }); @@ -140,7 +179,27 @@ describe('ContactDetailFilters', () => { const result = $ContactDetailFilters.safeParse(data); expect(result.success).toBe(false); + expect(result.error).toMatchSnapshot(); + }); +}); + +describe('$VerifyContactDetailInput', () => { + test('parses valid verify contact detail input', () => { + expect( + $VerifyContactDetailInput.parse({ + otp: '123456', + }) + ).toEqual({ + otp: '123456', + }); + }); + test('throws error if invalid', () => { + const result = $VerifyContactDetailInput.safeParse({ + contactDetailId: 'contact-1', + }); + + expect(result.success).toBe(false); expect(result.error).toMatchSnapshot(); }); }); diff --git a/lambdas/backend-client/src/schemas/client.ts b/lambdas/backend-client/src/schemas/client.ts index 6f78bb73de..ac73d60582 100644 --- a/lambdas/backend-client/src/schemas/client.ts +++ b/lambdas/backend-client/src/schemas/client.ts @@ -7,7 +7,7 @@ import { schemaFor } from './schema-for'; const $ClientFeatures = schemaFor()( z.object({ - proofing: z.boolean(), + proofing: z.boolean().optional(), routing: z.boolean().optional(), letterAuthoring: z.boolean().optional(), legacyLetters: z.boolean().optional(), diff --git a/lambdas/backend-client/src/schemas/contact-details/index.ts b/lambdas/backend-client/src/schemas/contact-details/index.ts index 2ec4be0a8b..b111649d94 100644 --- a/lambdas/backend-client/src/schemas/contact-details/index.ts +++ b/lambdas/backend-client/src/schemas/contact-details/index.ts @@ -1,10 +1,10 @@ import { z } from 'zod/v4'; import type { - ContactDetail, ContactDetailInputNormalized, ContactDetailStatus, ContactDetailType, GetV1ContactDetailsData, + VerifyContactDetailInput, } from 'nhs-notify-web-template-management-types'; import { parsePhoneNumber } from './phone-number'; import { parseEmailAddress } from './email'; @@ -89,10 +89,22 @@ export const $ContactDetailFilters: z.ZodType< export type ContactDetailFilters = z.infer; -export const $ContactDetail: z.ZodType = z.object({ +export const $ContactDetailDto = z.object({ id: z.string(), type: $ContactDetailType, status: $ContactDetailStatus, rawValue: z.string(), value: z.string(), }); + +export const $DatabaseContactDetail = $ContactDetailDto.extend({ + rawValue: z.string(), + otpHash: z.string().optional(), + clientId: z.string(), +}); +export type DatabaseContactDetail = z.infer; + +export const $VerifyContactDetailInput: z.ZodType = + z.object({ + otp: z.string(), + }); diff --git a/lambdas/backend-client/src/types/error-cases.ts b/lambdas/backend-client/src/types/error-cases.ts index bb53fcf465..7c185ca1b6 100644 --- a/lambdas/backend-client/src/types/error-cases.ts +++ b/lambdas/backend-client/src/types/error-cases.ts @@ -13,4 +13,5 @@ export enum ErrorCase { TEMPLATE_IN_USE = 400, ROUTING_CONFIG_TEMPLATES_NOT_FOUND = 400, ROUTING_CONFIG_TEMPLATES_INVALID = 400, + INVALID_VERIFICATION = 400, } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d2e29e1bbe..a0b2952e3e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -22,7 +22,7 @@ export type { ClientOptions, ConditionalTemplateAccessible, ConditionalTemplateLanguage, - ContactDetail, + ContactDetailDto, ContactDetailInput, ContactDetailInputNormalized, ContactDetailStatus, @@ -181,6 +181,11 @@ export type { PostV1TemplateErrors, PostV1TemplateResponse, PostV1TemplateResponses, + PostV1VerifyContactDetailByContactDetailIdData, + PostV1VerifyContactDetailByContactDetailIdError, + PostV1VerifyContactDetailByContactDetailIdErrors, + PostV1VerifyContactDetailByContactDetailIdResponse, + PostV1VerifyContactDetailByContactDetailIdResponses, ProofFileDetails, ProofRequest, ProofRequestSuccess, @@ -211,6 +216,7 @@ export type { UpdateRoutingConfig, UploadLetterTemplate, ValidationErrorDetail, + VerifyContactDetailInput, VersionedFileDetails, VirusScanStatus, } from './types.gen'; diff --git a/packages/types/src/types.gen.ts b/packages/types/src/types.gen.ts index 97df7fccac..cd1e45481f 100644 --- a/packages/types/src/types.gen.ts +++ b/packages/types/src/types.gen.ts @@ -133,10 +133,12 @@ export type ConditionalTemplateLanguage = { templateId: string; }; -export type ContactDetail = ContactDetailInput & { +export type ContactDetailDto = { id: string; rawValue: string; status: ContactDetailStatus; + type: ContactDetailType; + value: string; }; export type ContactDetailInput = { @@ -151,12 +153,12 @@ export type ContactDetailInputNormalized = ContactDetailInput & { export type ContactDetailStatus = 'PENDING_VERIFICATION' | 'VERIFIED'; export type ContactDetailSuccess = { - data: ContactDetail; + data: ContactDetailDto; statusCode: number; }; export type ContactDetailSuccessList = { - data: Array; + data: Array; statusCode: number; }; @@ -463,6 +465,10 @@ export type ValidationErrorDetail = { name: LetterValidationError; }; +export type VerifyContactDetailInput = { + otp: string; +}; + export type VersionedFileDetails = { currentVersion: string; fileName: string; @@ -1416,3 +1422,35 @@ export type GetV1TemplatesResponses = { export type GetV1TemplatesResponse = GetV1TemplatesResponses[keyof GetV1TemplatesResponses]; + +export type PostV1VerifyContactDetailByContactDetailIdData = { + body?: VerifyContactDetailInput; + path: { + /** + * ID of the contact detail + */ + contactDetailId: string; + }; + query?: never; + url: '/v1/verify-contact-detail/{contactDetailId}'; +}; + +export type PostV1VerifyContactDetailByContactDetailIdErrors = { + /** + * Error + */ + default: Failure; +}; + +export type PostV1VerifyContactDetailByContactDetailIdError = + PostV1VerifyContactDetailByContactDetailIdErrors[keyof PostV1VerifyContactDetailByContactDetailIdErrors]; + +export type PostV1VerifyContactDetailByContactDetailIdResponses = { + /** + * 200 response + */ + 200: ContactDetailSuccess; +}; + +export type PostV1VerifyContactDetailByContactDetailIdResponse = + PostV1VerifyContactDetailByContactDetailIdResponses[keyof PostV1VerifyContactDetailByContactDetailIdResponses]; diff --git a/tests/test-team/global.d.ts b/tests/test-team/global.d.ts index f86af13304..9de92b4272 100644 --- a/tests/test-team/global.d.ts +++ b/tests/test-team/global.d.ts @@ -7,6 +7,7 @@ declare global { COGNITO_USER_POOL_CLIENT_ID: string; COGNITO_USER_POOL_ID: string; CONTACT_DETAILS_TABLE_NAME: string; + CONTACT_DETAILS_OTP_SECRET_PARAMETER_NAME: string; ENVIRONMENT: string; EVENTS_SNS_TOPIC_ARN: string; LETTER_VARIANTS_TABLE_NAME: string; diff --git a/tests/test-team/helpers/db/contact-details-helper.ts b/tests/test-team/helpers/db/contact-details-helper.ts index 1cfd314ff1..069a556ce7 100644 --- a/tests/test-team/helpers/db/contact-details-helper.ts +++ b/tests/test-team/helpers/db/contact-details-helper.ts @@ -1,4 +1,6 @@ +import { createHmac, type Hmac } from 'node:crypto'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm'; import { BatchWriteCommand, DynamoDBDocumentClient, @@ -17,6 +19,10 @@ export class ContactDetailHelper { new DynamoDBClient({ region: 'eu-west-2' }) ); + static readonly ssm = new SSMClient({ + region: 'eu-west-2', + }); + private adHocKeys: ContactDetailKey[] = []; private seedData: ContactDetailKey[] = []; /** @@ -26,6 +32,43 @@ export class ContactDetailHelper { this.adHocKeys.push(key); } + private static updateWithLengthPrefix(hmac: Hmac, value: string): void { + hmac.update(`${value.length}:`); + hmac.update(value); + } + + private static hashContactDetailsOtp( + id: string, + value: string, + otp: string, + secret: string + ): string { + const hmac = createHmac('sha256', secret); + + this.updateWithLengthPrefix(hmac, id); + this.updateWithLengthPrefix(hmac, value); + this.updateWithLengthPrefix(hmac, otp); + + return hmac.digest().toString('hex'); + } + + static async getOtpHash(id: string, value: string, otp: string) { + const { Parameter } = await this.ssm.send( + new GetParameterCommand({ + Name: process.env.CONTACT_DETAILS_OTP_SECRET_PARAMETER_NAME, + WithDecryption: true, + }) + ); + + const secret = Parameter?.Value; + + if (!secret) { + throw new Error('No secret returned from parameter store.'); + } + + return this.hashContactDetailsOtp(id, value, otp, secret); + } + /** * Seed a load of contact details into the database */ diff --git a/tests/test-team/helpers/factories/contact-details-factory.ts b/tests/test-team/helpers/factories/contact-details-factory.ts index 324e3e7e50..dcc4bffbf6 100644 --- a/tests/test-team/helpers/factories/contact-details-factory.ts +++ b/tests/test-team/helpers/factories/contact-details-factory.ts @@ -5,17 +5,21 @@ export type FactoryContactDetail = { id: string; status: string; value: string; - rawValue?: string; type: string; owner: string; + otpHash: string; + rawValue: string; + clientId: string; }; const makeContactDetail = ( - input: Omit & - Pick + input: Partial & + Omit ): FactoryContactDetail => ({ id: randomUUID(), rawValue: input.value, + clientId: 'client-id', + otpHash: 'otp-hash', ...input, }); @@ -24,12 +28,13 @@ export const makeVerifiedContactDetail = ( ): FactoryContactDetail => makeContactDetail({ status: 'VERIFIED', ...input }); export const makeUnverifiedContactDetail = ( - input: Pick + input: Partial & + Pick ): FactoryContactDetail => makeContactDetail({ status: 'PENDING_VERIFICATION', ...input }); export const makeUnverifiedEmailContactDetail = ( - input: Pick + input: Partial & Pick ) => makeUnverifiedContactDetail({ type: 'EMAIL', @@ -38,7 +43,7 @@ export const makeUnverifiedEmailContactDetail = ( }); export const makeUnverifiedSmsContactDetail = ( - input: Pick + input: Partial & Pick ) => makeUnverifiedContactDetail({ type: 'SMS', @@ -47,7 +52,7 @@ export const makeUnverifiedSmsContactDetail = ( }); export const makeVerifiedEmailContactDetail = ( - input: Pick + input: Partial & Pick ) => makeVerifiedContactDetail({ type: 'EMAIL', @@ -56,7 +61,7 @@ export const makeVerifiedEmailContactDetail = ( }); export const makeVerifiedSmsContactDetail = ( - input: Pick + input: Partial & Pick ) => makeVerifiedContactDetail({ type: 'SMS', diff --git a/tests/test-team/template-mgmt-api-tests/verify-contact-detail.api.spec.ts b/tests/test-team/template-mgmt-api-tests/verify-contact-detail.api.spec.ts new file mode 100644 index 0000000000..a9055b432a --- /dev/null +++ b/tests/test-team/template-mgmt-api-tests/verify-contact-detail.api.spec.ts @@ -0,0 +1,161 @@ +import { test, expect } from '@playwright/test'; +import { TestUser, testUsers } from 'helpers/auth/cognito-auth-helper'; +import { getTestContext } from 'helpers/context/context'; +import { ContactDetailHelper } from 'helpers/db/contact-details-helper'; +import { makeUnverifiedEmailContactDetail } from 'helpers/factories/contact-details-factory'; + +const pendingVerificationContactDetailId = + 'cc1b6c48-11c2-4093-87b2-0ab3fff197b9'; + +const pendingVerificationContactDetail = makeUnverifiedEmailContactDetail({ + owner: testUsers.User1.internalUserId, + id: pendingVerificationContactDetailId, + value: 'test-email@nhs.net', +}); + +test.describe('POST /v1/verify-contact-detail/{contactDetailId}', () => { + let user: TestUser; + const contactDetailHelper = new ContactDetailHelper(); + const context = getTestContext(); + + test.beforeAll(async () => { + user = await context.auth.getTestUser(testUsers.User1.userId); + + contactDetailHelper.seed([pendingVerificationContactDetail]); + }); + + test.afterAll(async () => { + await contactDetailHelper.cleanup(); + }); + + test('returns 401 if no auth token on request', async ({ request }) => { + const response = await request.post( + `${process.env.API_BASE_URL}/v1/verify-contact-detail/${pendingVerificationContactDetailId}`, + { + data: { + otp: '123456', + }, + } + ); + + expect(response.status()).toBe(401); + expect(await response.json()).toEqual({ + message: 'Unauthorized', + }); + }); + + test('returns 400 if body is missing otp', async ({ request }) => { + const response = await request.post( + `${process.env.API_BASE_URL}/v1/verify-contact-detail/${pendingVerificationContactDetailId}`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: {}, + } + ); + + expect(response.status()).toBe(400); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 400, + technicalMessage: 'Request failed validation', + details: { + otp: 'Invalid input: expected string, received undefined', + }, + }); + }); + + test('returns 404 if contact detail does not exist', async ({ request }) => { + const response = await request.post( + `${process.env.API_BASE_URL}/v1/verify-contact-detail/non-existent-id`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + otp: '123456', + }, + } + ); + + expect(response.status()).toBe(404); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 404, + technicalMessage: 'Contact details not found.', + }); + }); + + test('returns 400 with invalid OTP', async ({ request }) => { + const response = await request.post( + `${process.env.API_BASE_URL}/v1/verify-contact-detail/${pendingVerificationContactDetailId}`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + otp: '00000', + }, + } + ); + + expect(response.status()).toBe(400); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 400, + technicalMessage: 'Invalid contact detail verification', + }); + }); + + test('returns 200 with valid OTP', async ({ request }) => { + const contactDetailId = 'a13e9189-e3cc-4ccf-a629-13374c170d3b'; + const contactDetailValue = 'test-email-2@nhs.net'; + const otp = '015678'; + + const otpHash = await ContactDetailHelper.getOtpHash( + contactDetailId, + contactDetailValue, + otp + ); + + const contactDetail = makeUnverifiedEmailContactDetail({ + owner: testUsers.User1.internalUserId, + id: contactDetailId, + value: contactDetailValue, + otpHash, + }); + + await contactDetailHelper.seed([contactDetail]); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/verify-contact-detail/${contactDetailId}`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + otp, + }, + } + ); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 200, + data: { + id: contactDetailId, + status: 'VERIFIED', + type: contactDetail.type, + value: contactDetail.value, + }, + }); + }); +}); diff --git a/utils/backend-config/src/backend-config.ts b/utils/backend-config/src/backend-config.ts index ec1caca04d..bebcc9c1e3 100644 --- a/utils/backend-config/src/backend-config.ts +++ b/utils/backend-config/src/backend-config.ts @@ -7,6 +7,7 @@ export type BackendConfig = { awsAccountId: string; clientSsmPathPrefix: string; contactDetailsTableName: string; + contactDetailsOtpSecretParameterName: string; environment: string; eventsSnsTopicArn: string; letterVariantsTableName: string; @@ -34,6 +35,8 @@ export const BackendConfigHelper = { awsAccountId: process.env.AWS_ACCOUNT_ID ?? '', clientSsmPathPrefix: process.env.CLIENT_SSM_PATH_PREFIX ?? '', contactDetailsTableName: process.env.CONTACT_DETAILS_TABLE_NAME ?? '', + contactDetailsOtpSecretParameterName: + process.env.CONTACT_DETAILS_OTP_SECRET_PARAMETER_NAME ?? '', environment: process.env.ENVIRONMENT ?? '', eventsSnsTopicArn: process.env.EVENTS_SNS_TOPIC_ARN ?? '', letterVariantsTableName: process.env.LETTER_VARIANTS_TABLE_NAME ?? '', @@ -64,6 +67,8 @@ export const BackendConfigHelper = { process.env.AWS_ACCOUNT_ID = config.awsAccountId; process.env.CLIENT_SSM_PATH_PREFIX = config.clientSsmPathPrefix; process.env.CONTACT_DETAILS_TABLE_NAME = config.contactDetailsTableName; + process.env.CONTACT_DETAILS_OTP_SECRET_PARAMETER_NAME = + config.contactDetailsOtpSecretParameterName; process.env.ENVIRONMENT = config.environment; process.env.EVENTS_SNS_TOPIC_ARN = config.eventsSnsTopicArn; process.env.COGNITO_USER_POOL_ID = config.userPoolId; @@ -99,6 +104,9 @@ export const BackendConfigHelper = { outputsFileContent.client_ssm_path_prefix?.value ?? '', contactDetailsTableName: outputsFileContent.contact_details_table_name?.value ?? '', + contactDetailsOtpSecretParameterName: + outputsFileContent.contact_details_otp_secret_parameter_name?.value ?? + '', environment: deployment.environment ?? '', eventsSnsTopicArn: outputsFileContent.events_sns_topic_arn?.value ?? '', proofRequestsTableName: From 00115138878dcb177f200699e7288264dd1e9316 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Fri, 15 May 2026 11:58:46 +0100 Subject: [PATCH 2/8] CCM-15087: Fix linting --- .../src/__tests__/infra/contact-details-repository.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts b/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts index 9a262983a4..8843b09ace 100644 --- a/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts @@ -852,7 +852,7 @@ describe('ContactDetailsRepository', () => { const result = await repo.getById( '8a7cbed5-ad44-4b8a-ae86-5e52c9caf4bb', USER, - false, + false ); expect(result.data).toBeUndefined(); @@ -870,7 +870,7 @@ describe('ContactDetailsRepository', () => { const result = await repo.getById( '8a7cbed5-ad44-4b8a-ae86-5e52c9caf4bb', USER, - false, + false ); expect(result.data).toBeUndefined(); From 82797a0d8419de78bd45c796a69d265cbf86e536 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Mon, 18 May 2026 09:14:32 +0100 Subject: [PATCH 3/8] CCM-15087: rebase fixes --- .../api/create-contact-details.test.ts | 1 - .../api/verify-contact-detail.test.ts | 1 + .../app/contact-details-client.test.ts | 20 +++++++++---------- .../app/proofing-request-client.test.ts | 9 +++++---- .../infra/contact-details-repository.test.ts | 2 ++ .../src/app/proofing-request-client.ts | 3 ++- .../factories/contact-details-factory.ts | 3 ++- 7 files changed, 22 insertions(+), 17 deletions(-) diff --git a/lambdas/backend-api/src/__tests__/api/create-contact-details.test.ts b/lambdas/backend-api/src/__tests__/api/create-contact-details.test.ts index 6b83a0f393..fc5bc14ca1 100644 --- a/lambdas/backend-api/src/__tests__/api/create-contact-details.test.ts +++ b/lambdas/backend-api/src/__tests__/api/create-contact-details.test.ts @@ -142,7 +142,6 @@ describe('Create Contact Details Handler', () => { status: 'PENDING_VERIFICATION', clientId: 'client-id', otpHash: 'otp-hash', - rawValue: 'raw-value', }; mocks.contactDetailsClient.create.mockResolvedValueOnce({ diff --git a/lambdas/backend-api/src/__tests__/api/verify-contact-detail.test.ts b/lambdas/backend-api/src/__tests__/api/verify-contact-detail.test.ts index f1196bd82b..5700f11726 100644 --- a/lambdas/backend-api/src/__tests__/api/verify-contact-detail.test.ts +++ b/lambdas/backend-api/src/__tests__/api/verify-contact-detail.test.ts @@ -138,6 +138,7 @@ describe('Verify Contact Detail Handler', () => { type: 'EMAIL', value: 'test@nhs.net', status: 'VERIFIED', + rawValue: 'raw-value', }; mocks.contactDetailsClient.verify.mockResolvedValueOnce({ diff --git a/lambdas/backend-api/src/__tests__/app/contact-details-client.test.ts b/lambdas/backend-api/src/__tests__/app/contact-details-client.test.ts index 31fa8e84a5..2ec45832d2 100644 --- a/lambdas/backend-api/src/__tests__/app/contact-details-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/contact-details-client.test.ts @@ -16,15 +16,14 @@ const OTP = '1234'; function setup() { const contactDetailsRepo = mock(); - contactDetailsRepo.putContactDetail.mockImplementation( - ({ rawValue, ...input }) => - Promise.resolve({ - data: { - ...input, - id: 'contact-details-id', - status: 'PENDING_VERIFICATION', - }, - }) + contactDetailsRepo.putContactDetail.mockImplementation((input) => + Promise.resolve({ + data: { + ...input, + id: 'contact-details-id', + status: 'PENDING_VERIFICATION', + }, + }) ); contactDetailsRepo.list.mockResolvedValue({ data: [], @@ -194,13 +193,13 @@ describe('ContactDetailsClient', () => { status: 'PENDING_VERIFICATION' as const, type: 'EMAIL' as const, value: 'test@nhs.net', + rawValue: 'test@nhs.net', }; const contactDetail: DatabaseContactDetail = { ...contactDetailDto, clientId: USER.clientId, otpHash: 'otp-hash', - rawValue: 'test@nhs.net', }; mocks.contactDetailsRepo.getById.mockResolvedValueOnce({ @@ -230,6 +229,7 @@ describe('ContactDetailsClient', () => { status: 'VERIFIED', type: 'EMAIL', value: 'test@nhs.net', + rawValue: 'test@nhs.net', }, }); diff --git a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts index f449029d3e..2b8ca5ba04 100644 --- a/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/proofing-request-client.test.ts @@ -1,7 +1,7 @@ import { mock } from 'jest-mock-extended'; import type { ClientConfiguration, - ContactDetail, + ContactDetailDto, ProofRequest, } from 'nhs-notify-web-template-management-types'; import type { User } from 'nhs-notify-web-template-management-utils'; @@ -44,7 +44,7 @@ const proofRequest: ProofRequest = { createdBy: 'INTERNAL_USER#user-id', }; -const contactDetailVerifiedEmail: ContactDetail = { +const contactDetailVerifiedEmail: ContactDetailDto = { id: 'contact-id', type: 'EMAIL', value: 'test@nhs.net', @@ -52,7 +52,7 @@ const contactDetailVerifiedEmail: ContactDetail = { status: 'VERIFIED', }; -const contactDetailVerifiedSms: ContactDetail = { +const contactDetailVerifiedSms: ContactDetailDto = { id: 'contact-id', type: 'SMS', value: '+447890123456', @@ -507,7 +507,8 @@ describe('ProofingRequestClient', () => { ); expect(mocks.contactDetailsRepository.getById).toHaveBeenCalledWith( 'cd-id', - user + user, + false ); }); } diff --git a/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts b/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts index 8843b09ace..d35add69f9 100644 --- a/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts @@ -311,6 +311,7 @@ describe('ContactDetailsRepository', () => { status: 'VERIFIED', type: 'EMAIL', value: 'test@nhs.net', + rawValue: 'test@nhs.net', }, }); @@ -322,6 +323,7 @@ describe('ContactDetailsRepository', () => { status: 'VERIFIED', type: 'EMAIL', value: 'test@nhs.net', + rawValue: 'test@nhs.net', }, }); diff --git a/lambdas/backend-api/src/app/proofing-request-client.ts b/lambdas/backend-api/src/app/proofing-request-client.ts index 97b03c1663..b7cba684f6 100644 --- a/lambdas/backend-api/src/app/proofing-request-client.ts +++ b/lambdas/backend-api/src/app/proofing-request-client.ts @@ -134,7 +134,8 @@ export class ProofingRequestClient { const contactDetailResult = await this.contactDetailsRepository.getById( contactDetailId, - user + user, + false ); if (contactDetailResult.error) { diff --git a/tests/test-team/helpers/factories/contact-details-factory.ts b/tests/test-team/helpers/factories/contact-details-factory.ts index dcc4bffbf6..1e71665abf 100644 --- a/tests/test-team/helpers/factories/contact-details-factory.ts +++ b/tests/test-team/helpers/factories/contact-details-factory.ts @@ -24,7 +24,8 @@ const makeContactDetail = ( }); export const makeVerifiedContactDetail = ( - input: Pick + input: Partial & + Pick ): FactoryContactDetail => makeContactDetail({ status: 'VERIFIED', ...input }); export const makeUnverifiedContactDetail = ( From 5502ce115ea4a1abb95dbdeee32b29ae2cd9dd70 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Mon, 18 May 2026 09:57:49 +0100 Subject: [PATCH 4/8] CCM-15087: Fix CI --- .../terraform/modules/backend-api/spec.tmpl.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index 3f3df4cf97..4f6faa1473 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -478,15 +478,15 @@ "id": { "type": "string" }, + "rawValue": { + "type": "string" + }, "status": { "$ref": "#/components/schemas/ContactDetailStatus" }, "type": { "$ref": "#/components/schemas/ContactDetailType" }, - "rawValue": { - "type": "string" - }, "value": { "type": "string" } @@ -494,6 +494,7 @@ "required": [ "type", "value", + "rawValue", "id", "status" ], From 452999d978e1365b0590d0227215fecfc673db66 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Mon, 18 May 2026 10:21:56 +0100 Subject: [PATCH 5/8] CCM-15087: Fix API tests --- .../template-mgmt-api-tests/verify-contact-detail.api.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test-team/template-mgmt-api-tests/verify-contact-detail.api.spec.ts b/tests/test-team/template-mgmt-api-tests/verify-contact-detail.api.spec.ts index a9055b432a..05ab4901bf 100644 --- a/tests/test-team/template-mgmt-api-tests/verify-contact-detail.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/verify-contact-detail.api.spec.ts @@ -155,6 +155,7 @@ test.describe('POST /v1/verify-contact-detail/{contactDetailId}', () => { status: 'VERIFIED', type: contactDetail.type, value: contactDetail.value, + rawValue: contactDetail.value, }, }); }); From 59e2c70d3ab6755f66ecd9d0945d91df9c3d57c8 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Mon, 18 May 2026 11:07:25 +0100 Subject: [PATCH 6/8] CCM-15087: Documentation --- lambdas/backend-api/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lambdas/backend-api/README.md b/lambdas/backend-api/README.md index a04a9b79d7..b1afbc1905 100644 --- a/lambdas/backend-api/README.md +++ b/lambdas/backend-api/README.md @@ -301,6 +301,15 @@ curl -X POST --location "${APIG_STAGE}/v1/contact-details" \ --data '{ "type": "SMS", "value": "07890123456" }' ``` +### POST - /v1/verify-contact-detail/:contactDetailId - Verify a contact detail + +```bash +curl -X POST --location "${APIG_STAGE}/v1/verify-contact-detail/{CONTACT_DETAIL_ID}" \ + --header "Accept: application/json" \ + --header "Authorization: $SANDBOX_TOKEN" \ + --data '{ "otp": "015678" }' +``` + ### GET - /v1/contact-details - Retrieve a list of contact details for the authenticated user, filterable by status and type ```bash From 84b03d6919749ad0f8681b162d5d504415ea5f9b Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Mon, 18 May 2026 12:40:47 +0100 Subject: [PATCH 7/8] CCM-15087: Review comments --- .../src/__tests__/infra/contact-details-repository.test.ts | 5 ++++- lambdas/backend-api/src/infra/contact-details-repository.ts | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts b/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts index d35add69f9..a4bd925422 100644 --- a/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts +++ b/lambdas/backend-api/src/__tests__/infra/contact-details-repository.test.ts @@ -330,7 +330,8 @@ describe('ContactDetailsRepository', () => { expect(mocks.dynamodb).toHaveReceivedCommandWith(UpdateCommand, { TableName: TABLE_NAME, Key: expectedKey, - UpdateExpression: 'SET #status = :verifiedStatus REMOVE #ttl, otpHash', + UpdateExpression: + 'SET #status = :verifiedStatus, updatedAt = :updatedAt, updatedBy = :updatedBy REMOVE #ttl, otpHash', ExpressionAttributeNames: { '#status': 'status', '#ttl': 'ttl', @@ -339,6 +340,8 @@ describe('ContactDetailsRepository', () => { ':verifiedStatus': 'VERIFIED', ':pendingVerificationStatus': 'PENDING_VERIFICATION', ':id': contactDetail.id, + ':updatedAt': NOW.toISOString(), + ':updatedBy': 'INTERNAL_USER#user-id', }, ConditionExpression: '#status = :pendingVerificationStatus AND id = :id', diff --git a/lambdas/backend-api/src/infra/contact-details-repository.ts b/lambdas/backend-api/src/infra/contact-details-repository.ts index f6f04945e6..00d1eb52ac 100644 --- a/lambdas/backend-api/src/infra/contact-details-repository.ts +++ b/lambdas/backend-api/src/infra/contact-details-repository.ts @@ -40,13 +40,15 @@ export class ContactDetailsRepository { contactDetail: ContactDetailDto, user: User ): Promise> { + const now = new Date(); + try { const { Attributes } = await this.dynamodb.send( new UpdateCommand({ TableName: this.tableName, Key: this.getKey(contactDetail, user), UpdateExpression: - 'SET #status = :verifiedStatus REMOVE #ttl, otpHash', + 'SET #status = :verifiedStatus, updatedAt = :updatedAt, updatedBy = :updatedBy REMOVE #ttl, otpHash', ExpressionAttributeNames: { '#status': 'status', '#ttl': 'ttl', @@ -55,6 +57,8 @@ export class ContactDetailsRepository { ':verifiedStatus': 'VERIFIED', ':pendingVerificationStatus': 'PENDING_VERIFICATION', ':id': contactDetail.id, + ':updatedAt': now.toISOString(), + ':updatedBy': this.getOwnerKey(user), }, ConditionExpression: '#status = :pendingVerificationStatus AND id = :id', From dd21e05c27956726ebec1d7094ae9ae2c4a35ac5 Mon Sep 17 00:00:00 2001 From: Chris Elliott Date: Tue, 19 May 2026 15:46:47 +0100 Subject: [PATCH 8/8] CCM-15087: Fix tests --- .../app/contact-details-client.test.ts | 34 ++++++++ .../src/app/contact-details-client.ts | 4 + .../verify-contact-detail.api.spec.ts | 80 +++++++++++++++++-- .../proof-requests.event.spec.ts | 6 +- 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/lambdas/backend-api/src/__tests__/app/contact-details-client.test.ts b/lambdas/backend-api/src/__tests__/app/contact-details-client.test.ts index 2ec45832d2..0a8d4d6ef9 100644 --- a/lambdas/backend-api/src/__tests__/app/contact-details-client.test.ts +++ b/lambdas/backend-api/src/__tests__/app/contact-details-client.test.ts @@ -288,6 +288,40 @@ describe('ContactDetailsClient', () => { expect(result).toBe(error); }); + it('returns failure when contact detail is already verified', async () => { + const { client, mocks } = setup(); + + const contactDetail = { + id: 'contact-detail-id', + clientId: USER.clientId, + status: 'VERIFIED' as const, + type: 'SMS' as const, + value: '+447890123456', + rawValue: '07890123456', + }; + + mocks.contactDetailsRepo.getById.mockResolvedValueOnce({ + data: contactDetail, + }); + + const result = await client.verify( + 'contact-detail-id', + { otp: 'otp-hash' }, + USER + ); + + expect(result).toEqual({ + error: { + errorMeta: { + code: 409, + description: 'Contact detail already verified', + }, + }, + }); + + expect(mocks.contactDetailsRepo.updateToVerified).not.toHaveBeenCalled(); + }); + it('returns failure when OTP does not match', async () => { const { client, mocks } = setup(); diff --git a/lambdas/backend-api/src/app/contact-details-client.ts b/lambdas/backend-api/src/app/contact-details-client.ts index 6529aeb090..67a1dce090 100644 --- a/lambdas/backend-api/src/app/contact-details-client.ts +++ b/lambdas/backend-api/src/app/contact-details-client.ts @@ -64,6 +64,10 @@ export class ContactDetailsClient { if (contactDetail.error) return contactDetail; + if (contactDetail.data.status === 'VERIFIED') { + return failure(ErrorCase.CONFLICT, 'Contact detail already verified'); + } + const otpHash = await this.contactDetailsRepo.hashOtp( contactDetail.data, otp diff --git a/tests/test-team/template-mgmt-api-tests/verify-contact-detail.api.spec.ts b/tests/test-team/template-mgmt-api-tests/verify-contact-detail.api.spec.ts index 05ab4901bf..690343359c 100644 --- a/tests/test-team/template-mgmt-api-tests/verify-contact-detail.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/verify-contact-detail.api.spec.ts @@ -2,15 +2,31 @@ import { test, expect } from '@playwright/test'; import { TestUser, testUsers } from 'helpers/auth/cognito-auth-helper'; import { getTestContext } from 'helpers/context/context'; import { ContactDetailHelper } from 'helpers/db/contact-details-helper'; -import { makeUnverifiedEmailContactDetail } from 'helpers/factories/contact-details-factory'; +import { + makeUnverifiedEmailContactDetail, + makeVerifiedEmailContactDetail, +} from 'helpers/factories/contact-details-factory'; const pendingVerificationContactDetailId = 'cc1b6c48-11c2-4093-87b2-0ab3fff197b9'; - const pendingVerificationContactDetail = makeUnverifiedEmailContactDetail({ owner: testUsers.User1.internalUserId, id: pendingVerificationContactDetailId, - value: 'test-email@nhs.net', + value: 'test-email-pending-verification@nhs.net', +}); + +const differentUserContactDetailId = 'a22e5fa8-a6d2-4416-b666-ff2de37c66c5'; +const differentUserContactDetail = makeUnverifiedEmailContactDetail({ + owner: 'some-other-user', + id: differentUserContactDetailId, + value: 'test-email-different-user@nhs.net', +}); + +const verifiedContactDetailId = 'fe338547-614c-44f2-9912-c876e2f31bb4'; +const verifiedContactDetail = makeVerifiedEmailContactDetail({ + owner: testUsers.User1.internalUserId, + id: verifiedContactDetailId, + value: 'test-email-verified@nhs.net', }); test.describe('POST /v1/verify-contact-detail/{contactDetailId}', () => { @@ -21,7 +37,11 @@ test.describe('POST /v1/verify-contact-detail/{contactDetailId}', () => { test.beforeAll(async () => { user = await context.auth.getTestUser(testUsers.User1.userId); - contactDetailHelper.seed([pendingVerificationContactDetail]); + contactDetailHelper.seed([ + pendingVerificationContactDetail, + differentUserContactDetail, + verifiedContactDetail, + ]); }); test.afterAll(async () => { @@ -91,6 +111,31 @@ test.describe('POST /v1/verify-contact-detail/{contactDetailId}', () => { }); }); + test('returns 404 if contact detail belongs to another user', async ({ + request, + }) => { + const response = await request.post( + `${process.env.API_BASE_URL}/v1/verify-contact-detail/${differentUserContactDetailId}`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + otp: '123456', + }, + } + ); + + expect(response.status()).toBe(404); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 404, + technicalMessage: 'Contact details not found.', + }); + }); + test('returns 400 with invalid OTP', async ({ request }) => { const response = await request.post( `${process.env.API_BASE_URL}/v1/verify-contact-detail/${pendingVerificationContactDetailId}`, @@ -114,9 +159,34 @@ test.describe('POST /v1/verify-contact-detail/{contactDetailId}', () => { }); }); + test('returns 409 when verifying an already verified contact detail', async ({ + request, + }) => { + const response = await request.post( + `${process.env.API_BASE_URL}/v1/verify-contact-detail/${verifiedContactDetailId}`, + { + headers: { + Authorization: await user.getAccessToken(), + }, + data: { + otp: '00000', + }, + } + ); + + expect(response.status()).toBe(409); + + const body = await response.json(); + + expect(body).toEqual({ + statusCode: 409, + technicalMessage: 'Contact detail already verified', + }); + }); + test('returns 200 with valid OTP', async ({ request }) => { const contactDetailId = 'a13e9189-e3cc-4ccf-a629-13374c170d3b'; - const contactDetailValue = 'test-email-2@nhs.net'; + const contactDetailValue = 'test-email-to-verify@nhs.net'; const otp = '015678'; const otpHash = await ContactDetailHelper.getOtpHash( diff --git a/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts b/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts index 8ace651e47..3e3ee490d8 100644 --- a/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/proof-requests.event.spec.ts @@ -207,8 +207,8 @@ test.describe('ProofRequestedEvent', () => { const contactDetail = makeVerifiedContactDetail({ type: 'EMAIL', - value: 'test@example.com', - rawValue: 'Test@Example.COM', + value: 'event-test@example.com', + rawValue: 'Event-Test@Example.COM', owner: user.internalUserId, }); await contactDetailHelper.seed([contactDetail]); @@ -258,7 +258,7 @@ test.describe('ProofRequestedEvent', () => { templateType: 'EMAIL', testPatientNhsNumber: '9000000009', contactDetails: { - email: 'Test@Example.COM', + email: 'Event-Test@Example.COM', }, personalisation, },