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 23c8355cce..a46461db3b 100644
--- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json
+++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json
@@ -473,31 +473,32 @@
],
"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"
+ "rawValue": {
+ "type": "string"
+ },
+ "status": {
+ "$ref": "#/components/schemas/ContactDetailStatus"
+ },
+ "type": {
+ "$ref": "#/components/schemas/ContactDetailType"
+ },
+ "value": {
+ "type": "string"
}
- ]
+ },
+ "required": [
+ "type",
+ "value",
+ "rawValue",
+ "id",
+ "status"
+ ],
+ "type": "object"
},
"ContactDetailInput": {
"properties": {
@@ -542,7 +543,7 @@
"ContactDetailSuccess": {
"properties": {
"data": {
- "$ref": "#/components/schemas/ContactDetail"
+ "$ref": "#/components/schemas/ContactDetailDTO"
},
"statusCode": {
"type": "integer"
@@ -558,7 +559,7 @@
"properties": {
"data": {
"items": {
- "$ref": "#/components/schemas/ContactDetail"
+ "$ref": "#/components/schemas/ContactDetailDTO"
},
"type": "array"
},
@@ -1580,6 +1581,17 @@
],
"type": "object"
},
+ "VerifyContactDetailInput": {
+ "properties": {
+ "otp": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "otp"
+ ],
+ "type": "object"
+ },
"VersionedFileDetails": {
"properties": {
"currentVersion": {
@@ -3531,6 +3543,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/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
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..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
@@ -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,13 @@ 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',
};
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..5700f11726
--- /dev/null
+++ b/lambdas/backend-api/src/__tests__/api/verify-contact-detail.test.ts
@@ -0,0 +1,172 @@
+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',
+ rawValue: 'raw-value',
+ };
+
+ 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..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
@@ -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,11 +16,10 @@ const OTP = '1234';
function setup() {
const contactDetailsRepo = mock();
- contactDetailsRepo.putContactDetail.mockImplementation((input, _, user) =>
+ contactDetailsRepo.putContactDetail.mockImplementation((input) =>
Promise.resolve({
data: {
...input,
- clientId: user.clientId,
id: 'contact-details-id',
status: 'PENDING_VERIFICATION',
},
@@ -56,7 +56,6 @@ describe('ContactDetailsClient', () => {
rawValue: ' TEST@nhs.net ',
},
expected: {
- clientId: USER.clientId,
id: 'contact-details-id',
type: 'EMAIL',
value: 'test@nhs.net',
@@ -72,7 +71,6 @@ describe('ContactDetailsClient', () => {
rawValue: '07890 123 456',
},
expected: {
- clientId: USER.clientId,
id: 'contact-details-id',
type: 'SMS',
value: '+447890123456',
@@ -186,6 +184,221 @@ 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',
+ rawValue: 'test@nhs.net',
+ };
+
+ const contactDetail: DatabaseContactDetail = {
+ ...contactDetailDto,
+ clientId: USER.clientId,
+ otpHash: 'otp-hash',
+ };
+
+ 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',
+ rawValue: '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 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();
+
+ 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 +431,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 +492,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 +512,8 @@ describe('ContactDetailsClient', () => {
expect(mocks.contactDetailsRepo.getById).toHaveBeenCalledWith(
contactDetail.id,
- USER
+ USER,
+ false
);
});
@@ -321,7 +535,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__/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__/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..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
@@ -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,114 @@ 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',
+ rawValue: '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',
+ rawValue: 'test@nhs.net',
+ },
+ });
+
+ expect(mocks.dynamodb).toHaveReceivedCommandWith(UpdateCommand, {
+ TableName: TABLE_NAME,
+ Key: expectedKey,
+ UpdateExpression:
+ 'SET #status = :verifiedStatus, updatedAt = :updatedAt, updatedBy = :updatedBy REMOVE #ttl, otpHash',
+ ExpressionAttributeNames: {
+ '#status': 'status',
+ '#ttl': 'ttl',
+ },
+ ExpressionAttributeValues: {
+ ':verifiedStatus': 'VERIFIED',
+ ':pendingVerificationStatus': 'PENDING_VERIFICATION',
+ ':id': contactDetail.id,
+ ':updatedAt': NOW.toISOString(),
+ ':updatedBy': 'INTERNAL_USER#user-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 +767,8 @@ describe('ContactDetailsRepository', () => {
const result = await repo.getById(
'8a7cbed5-ad44-4b8a-ae86-5e52c9caf4bb',
- USER
+ USER,
+ false
);
expect(result.data).toEqual({
@@ -684,6 +794,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 +856,8 @@ describe('ContactDetailsRepository', () => {
const result = await repo.getById(
'8a7cbed5-ad44-4b8a-ae86-5e52c9caf4bb',
- USER
+ USER,
+ false
);
expect(result.data).toBeUndefined();
@@ -708,7 +874,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..67a1dce090 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,55 @@ 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;
+
+ if (contactDetail.data.status === 'VERIFIED') {
+ return failure(ErrorCase.CONFLICT, 'Contact detail already verified');
+ }
+
+ 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 +109,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/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/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..00d1eb52ac 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,88 @@ export class ContactDetailsRepository {
private otpSecretPath: string
) {}
+ async updateToVerified(
+ 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, updatedAt = :updatedAt, updatedBy = :updatedBy REMOVE #ttl, otpHash',
+ ExpressionAttributeNames: {
+ '#status': 'status',
+ '#ttl': 'ttl',
+ },
+ ExpressionAttributeValues: {
+ ':verifiedStatus': 'VERIFIED',
+ ':pendingVerificationStatus': 'PENDING_VERIFICATION',
+ ':id': contactDetail.id,
+ ':updatedAt': now.toISOString(),
+ ':updatedBy': this.getOwnerKey(user),
+ },
+ 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 +136,7 @@ export class ContactDetailsRepository {
})
);
- return success(dto);
+ return success($ContactDetailDto.parse(contactDetail));
} catch (error) {
if (error instanceof ConditionalCheckFailedException) {
return failure(
@@ -97,7 +157,7 @@ export class ContactDetailsRepository {
async list(
user: User,
filters: ContactDetailFilters = {}
- ): Promise> {
+ ): Promise> {
const names: QueryCommandInput['ExpressionAttributeNames'] = {
'#owner': 'owner',
};
@@ -158,8 +218,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 +252,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 +270,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 +310,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 4f951489d2..d3bd2a610d 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..1e71665abf 100644
--- a/tests/test-team/helpers/factories/contact-details-factory.ts
+++ b/tests/test-team/helpers/factories/contact-details-factory.ts
@@ -5,31 +5,37 @@ 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,
});
export const makeVerifiedContactDetail = (
- input: Pick
+ input: Partial &
+ Pick
): 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 +44,7 @@ export const makeUnverifiedEmailContactDetail = (
});
export const makeUnverifiedSmsContactDetail = (
- input: Pick
+ input: Partial & Pick
) =>
makeUnverifiedContactDetail({
type: 'SMS',
@@ -47,7 +53,7 @@ export const makeUnverifiedSmsContactDetail = (
});
export const makeVerifiedEmailContactDetail = (
- input: Pick
+ input: Partial & Pick
) =>
makeVerifiedContactDetail({
type: 'EMAIL',
@@ -56,7 +62,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..690343359c
--- /dev/null
+++ b/tests/test-team/template-mgmt-api-tests/verify-contact-detail.api.spec.ts
@@ -0,0 +1,232 @@
+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,
+ 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-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}', () => {
+ let user: TestUser;
+ const contactDetailHelper = new ContactDetailHelper();
+ const context = getTestContext();
+
+ test.beforeAll(async () => {
+ user = await context.auth.getTestUser(testUsers.User1.userId);
+
+ contactDetailHelper.seed([
+ pendingVerificationContactDetail,
+ differentUserContactDetail,
+ verifiedContactDetail,
+ ]);
+ });
+
+ 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 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}`,
+ {
+ 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 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-to-verify@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,
+ rawValue: contactDetail.value,
+ },
+ });
+ });
+});
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,
},
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: