diff --git a/.github/scripts/deploy_api.sh b/.github/scripts/deploy_api.sh index 0652e38185..80e4f070eb 100755 --- a/.github/scripts/deploy_api.sh +++ b/.github/scripts/deploy_api.sh @@ -85,17 +85,11 @@ fi # Find and replace securitySchemes if [[ "${APIGEE_ENVIRONMENT}" == "prod" ]]; then - if [[ "${API_TYPE}" == "standard" ]]; then - jq '.components.securitySchemes."app-level3" = {"$ref": "https://proxygen.prod.api.platform.nhs.uk/components/securitySchemes/app-level3"}' "${SPEC_PATH}" > temp.json && mv temp.json "${SPEC_PATH}" - else - jq '.components.securitySchemes."app-level0" = {"$ref": "https://proxygen.prod.api.platform.nhs.uk/components/securitySchemes/app-level0"}' "${SPEC_PATH}" > temp.json && mv temp.json "${SPEC_PATH}" - fi + jq '.components.securitySchemes."app-level3" = {"$ref": "https://proxygen.prod.api.platform.nhs.uk/components/securitySchemes/app-level3"}' "${SPEC_PATH}" > temp.json && mv temp.json "${SPEC_PATH}" + jq '.components.securitySchemes."app-level0" = {"$ref": "https://proxygen.prod.api.platform.nhs.uk/components/securitySchemes/app-level0"}' "${SPEC_PATH}" > temp.json && mv temp.json "${SPEC_PATH}" else - if [[ "${API_TYPE}" == "standard" ]]; then - jq '.components.securitySchemes."app-level3" = {"$ref": "https://proxygen.ptl.api.platform.nhs.uk/components/securitySchemes/app-level3"}' "${SPEC_PATH}" > temp.json && mv temp.json "${SPEC_PATH}" - else - jq '.components.securitySchemes."app-level0" = {"$ref": "https://proxygen.ptl.api.platform.nhs.uk/components/securitySchemes/app-level0"}' "${SPEC_PATH}" > temp.json && mv temp.json "${SPEC_PATH}" - fi + jq '.components.securitySchemes."app-level3" = {"$ref": "https://proxygen.ptl.api.platform.nhs.uk/components/securitySchemes/app-level3"}' "${SPEC_PATH}" > temp.json && mv temp.json "${SPEC_PATH}" + jq '.components.securitySchemes."app-level0" = {"$ref": "https://proxygen.ptl.api.platform.nhs.uk/components/securitySchemes/app-level0"}' "${SPEC_PATH}" > temp.json && mv temp.json "${SPEC_PATH}" fi # Remove target attributes if the environment is sandbox diff --git a/.github/workflows/run_release_code_and_api.yml b/.github/workflows/run_release_code_and_api.yml index fc726915b6..d2ee5b4797 100644 --- a/.github/workflows/run_release_code_and_api.yml +++ b/.github/workflows/run_release_code_and_api.yml @@ -85,6 +85,7 @@ on: required: false REGRESSION_TESTS_PEM: required: true + jobs: release_code_and_api: runs-on: ubuntu-22.04 diff --git a/.vscode/eps-prescription-status-update-api.code-workspace b/.vscode/eps-prescription-status-update-api.code-workspace index d5cc30a36c..9a06354c8e 100644 --- a/.vscode/eps-prescription-status-update-api.code-workspace +++ b/.vscode/eps-prescription-status-update-api.code-workspace @@ -32,6 +32,10 @@ "name": "packages/nhsNotifyLambda", "path": "../packages/nhsNotifyLambda" }, + { + "name": "packages/nhsNotifyUpdateCallback", + "path": "../packages/nhsNotifyUpdateCallback" + }, { "name": "packages/capabilityStatement", "path": "../packages/capabilityStatement" @@ -97,6 +101,7 @@ "mermade", "milliliter", "mkhl", + "nhsapp", "nHSCHI", "NHSD", "nhsdlogin", diff --git a/Makefile b/Makefile index a8e780a894..509f9b4f8b 100644 --- a/Makefile +++ b/Makefile @@ -117,6 +117,7 @@ lint-node: compile-node npm run lint --workspace packages/cpsuLambda npm run lint --workspace packages/checkPrescriptionStatusUpdates npm run lint --workspace packages/nhsNotifyLambda + npm run lint --workspace packages/nhsNotifyUpdateCallback npm run lint --workspace packages/common/testing npm run lint --workspace packages/common/middyErrorHandler npm run lint --workspace packages/common/commonTypes @@ -147,6 +148,7 @@ test: compile npm run test --workspace packages/cpsuLambda npm run test --workspace packages/checkPrescriptionStatusUpdates npm run test --workspace packages/nhsNotifyLambda + npm run test --workspace packages/nhsNotifyUpdateCallback npm run test --workspace packages/common/middyErrorHandler clean: @@ -164,6 +166,8 @@ clean: rm -rf packages/cpsuLambda/lib rm -rf packages/nhsNotifyLambda/coverage rm -rf packages/nhsNotifyLambda/lib + rm -rf packages/nhsNotifyUpdateCallback/coverage + rm -rf packages/nhsNotifyUpdateCallback/lib rm -rf packages/checkPrescriptionStatusUpdates/lib rm -rf packages/common/testing/lib rm -rf packages/common/middyErrorHandler/lib diff --git a/README.md b/README.md index 31cf1b8cd1..70b4f50339 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ This is the AWS layer that provides an API for EPS Prescription Status Update. - `packages/capabilityStatement/` Returns a static capability statement. - `packages/cpsuLambda` Handles updating prescription status using a custom format. - `packages/nhsNotifyLambda` Handles sending prescription notifications to the NHS notify service. +- `packages/nhsNotifyUpdateCallback` Handles receiving notification updates from the NHS notify service. - `scripts/` Utilities helpful to developers of this specification. - `postman/` Postman collections to call the APIs. Documentation on how to use them are in the collections. - `SAMtemplates/` Contains the SAM templates used to define the stacks. diff --git a/SAMtemplates/apis/main.yaml b/SAMtemplates/apis/main.yaml index 87610f74c8..0c4728e000 100644 --- a/SAMtemplates/apis/main.yaml +++ b/SAMtemplates/apis/main.yaml @@ -54,6 +54,14 @@ Parameters: Type: String Default: none + NHSNotifyUpdateCallbackFunctionName: + Type: String + Default: none + + NHSNotifyUpdateCallbackFunctionArn: + Type: String + Default: none + LogRetentionInDays: Type: Number @@ -427,6 +435,32 @@ Resources: - StatusCode: "400" - StatusCode: "500" + NotificationDeliveryStatusCallbackMethod: + Type: AWS::ApiGateway::Method + Properties: + RestApiId: !Ref RestApiGateway + ResourceId: !Ref NotificationDeliveryStatusCallbackResource + HttpMethod: POST + AuthorizationType: NONE # They authenticate with a signature header + Integration: + Type: AWS_PROXY + Credentials: !GetAtt RestApiGatewayResources.Outputs.ApiGwRoleArn + IntegrationHttpMethod: POST + Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${NHSNotifyUpdateCallbackFunctionArn}/invocations + MethodResponses: + - StatusCode: "202" + - StatusCode: "401" + - StatusCode: "403" + - StatusCode: "429" + - StatusCode: "500" + + NotificationDeliveryStatusCallbackResource: + Type: AWS::ApiGateway::Resource + Properties: + RestApiId: !Ref RestApiGateway + ParentId: !GetAtt RestApiGateway.RootResourceId + PathPart: notification-delivery-status-callback + StatusLambdaMethodResource: Type: AWS::ApiGateway::Resource Properties: @@ -516,7 +550,7 @@ Resources: # if you add a new endpoint, then change the name of this resource # also need to change it in RestApiGatewayStage.Properties.DeploymentId # ********************************************************************* - RestApiGatewayDeploymentV1f: + RestApiGatewayDeploymentV2f: Type: AWS::ApiGateway::Deployment DependsOn: # see note above if you add something in here when you add a new endpoint @@ -525,6 +559,7 @@ Resources: - CapabilityStatementMethod - Format1UpdatePrescriptionStatusMethod - CheckPrescriptionStatusUpdatesWaitCondition + - NotificationDeliveryStatusCallbackMethod # see note above if you add something in here when you add a new endpoint Properties: RestApiId: !Ref RestApiGateway @@ -533,7 +568,7 @@ Resources: Type: AWS::ApiGateway::Stage Properties: RestApiId: !Ref RestApiGateway - DeploymentId: !Ref RestApiGatewayDeploymentV1f + DeploymentId: !Ref RestApiGatewayDeploymentV2f StageName: prod TracingEnabled: true AccessLogSetting: @@ -557,6 +592,7 @@ Resources: - - Fn::ImportValue: !Sub ${StackName}:state-machines:${UpdatePrescriptionStatusStateMachineName}:ExecuteStateMachinePolicy - Fn::ImportValue: !Sub ${StackName}:functions:${StatusFunctionName}:ExecuteLambdaPolicyArn - Fn::ImportValue: !Sub ${StackName}:functions:${CapabilityStatementFunctionName}:ExecuteLambdaPolicyArn + - Fn::ImportValue: !Sub ${StackName}:functions:${NHSNotifyUpdateCallbackFunctionName}:ExecuteLambdaPolicyArn - Fn::ImportValue: !Sub ${StackName}:state-machines:${Format1UpdatePrescriptionsStatusStateMachineName}:ExecuteStateMachinePolicy - !If - ShouldDeployCheckPrescriptionStatusUpdate diff --git a/SAMtemplates/functions/lambda_resources.yaml b/SAMtemplates/functions/lambda_resources.yaml index 41917bbe3f..25a71b376d 100644 --- a/SAMtemplates/functions/lambda_resources.yaml +++ b/SAMtemplates/functions/lambda_resources.yaml @@ -83,6 +83,7 @@ Resources: - !ImportValue lambda-resources:LambdaInsightsLogGroupPolicy - !ImportValue account-resources:CloudwatchEncryptionKMSPolicyArn - !ImportValue account-resources:LambdaDecryptSecretsKMSPolicy + - !ImportValue secrets:GetNotifySecretsManagedPolicy - !If - ShouldIncludeAdditionalPolicies - !Join diff --git a/SAMtemplates/functions/main.yaml b/SAMtemplates/functions/main.yaml index f19ad49b0c..ef053de2af 100644 --- a/SAMtemplates/functions/main.yaml +++ b/SAMtemplates/functions/main.yaml @@ -25,14 +25,18 @@ Parameters: Type: String Default: none - # PrescriptionNotificationStatesTableName: - # Type: String - # Default: none + PrescriptionNotificationStatesTableName: + Type: String + Default: none NHSNotifyPrescriptionsSQSQueueUrl: Type: String Default: none + SQSSaltSecret: + Type: String + Default: none + EnabledSiteODSCodesParam: Type: AWS::SSM::Parameter::Value @@ -41,7 +45,7 @@ Parameters: BlockedSiteODSCodesParam: Type: AWS::SSM::Parameter::Value - + LogLevel: Type: String @@ -69,17 +73,6 @@ Conditions: - !Ref DeployCheckPrescriptionStatusUpdate Resources: - SQSSaltSecret: - Type: AWS::SecretsManager::Secret - Properties: - Name: !Sub ${StackName}-SqsSalt - Description: Auto-generated salt for SQS_SALT - GenerateSecretString: - SecretStringTemplate: "{}" - GenerateStringKey: salt - PasswordLength: 32 - ExcludePunctuation: true - UpdatePrescriptionStatus: Type: AWS::Serverless::Function Properties: @@ -393,7 +386,7 @@ Resources: Variables: LOG_LEVEL: !Ref LogLevel NHS_NOTIFY_PRESCRIPTIONS_SQS_QUEUE_URL: !Ref NHSNotifyPrescriptionsSQSQueueUrl - # TABLE_NAME: !Ref PrescriptionNotificationStatesTableName + TABLE_NAME: !Ref PrescriptionNotificationStatesTableName Events: ScheduleEvent: Type: ScheduleV2 @@ -436,9 +429,58 @@ Resources: - - Fn::ImportValue: !Sub ${StackName}-WriteNHSNotifyPrescriptionsSQSQueuePolicyArn - Fn::ImportValue: !Sub ${StackName}-ReadNHSNotifyPrescriptionsSQSQueuePolicyArn - Fn::ImportValue: !Sub ${StackName}-UseNotificationSQSQueueKMSKeyPolicyArn - # - Fn::ImportValue: !Sub ${StackName}:tables:${PrescriptionNotificationStatesTableName}:TableReadPolicyArn - # - Fn::ImportValue: !Sub ${StackName}:tables:${PrescriptionNotificationStatesTableName}:TableWritePolicyArn - # - Fn::ImportValue: !Sub ${StackName}:tables:UsePrescriptionNotificationStatesKMSKeyPolicyArn + - Fn::ImportValue: !Sub ${StackName}:tables:${PrescriptionNotificationStatesTableName}:TableReadPolicyArn + - Fn::ImportValue: !Sub ${StackName}:tables:${PrescriptionNotificationStatesTableName}:TableWritePolicyArn + - Fn::ImportValue: !Sub ${StackName}:tables:UsePrescriptionNotificationStatesKMSKeyPolicyArn + + NHSNotifyUpdateCallback: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub ${StackName}-NHSNotifyUpdateCallback + CodeUri: ../../packages/ + Handler: lambdaHandler.handler + Role: !GetAtt NHSNotifyUpdateCallbackResources.Outputs.LambdaRoleArn + Environment: + Variables: + LOG_LEVEL: !Ref LogLevel + TABLE_NAME: !Ref PrescriptionNotificationStatesTableName + APP_NAME_SECRET: secrets-PSU-Notify-Application-Name + API_KEY_SECRET: secrets-PSU-Notify-API-Key + Metadata: + BuildMethod: esbuild + guard: + SuppressedRules: + - LAMBDA_DLQ_CHECK + - LAMBDA_INSIDE_VPC + - LAMBDA_CONCURRENCY_CHECK + BuildProperties: + Minify: true + Target: es2020 + Sourcemap: true + tsconfig: nhsNotifyUpdateCallback/tsconfig.json + packages: bundle + EntryPoints: + - nhsNotifyUpdateCallback/src/lambdaHandler.ts + + NHSNotifyUpdateCallbackResources: + Type: AWS::Serverless::Application + Properties: + Location: lambda_resources.yaml + Parameters: + StackName: !Ref StackName + LambdaName: !Sub ${StackName}-NHSNotifyUpdateCallback + LambdaArn: !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${StackName}-NHSNotifyUpdateCallback + IncludeAdditionalPolicies: true + AdditionalPolicies: !Join + - "," + - - Fn::ImportValue: !Sub ${StackName}:tables:${PrescriptionNotificationStatesTableName}:TableReadPolicyArn + - Fn::ImportValue: !Sub ${StackName}:tables:${PrescriptionNotificationStatesTableName}:TableWritePolicyArn + - Fn::ImportValue: !Sub ${StackName}:tables:UsePrescriptionNotificationStatesKMSKeyPolicyArn + LogRetentionInDays: !Ref LogRetentionInDays + CloudWatchKMSKeyId: !ImportValue account-resources:CloudwatchLogsKmsKeyArn + EnableSplunk: !Ref EnableSplunk + SplunkSubscriptionFilterRole: !ImportValue lambda-resources:SplunkSubscriptionFilterRole + SplunkDeliveryStreamArn: !ImportValue lambda-resources:SplunkDeliveryStream Outputs: UpdatePrescriptionStatusFunctionName: @@ -506,3 +548,11 @@ Outputs: NotifyProcessorFunctionArn: Description: The function ARN of the NHS Notify lambda Value: !GetAtt NotifyProcessor.Arn + + NHSNotifyUpdateCallbackFunctionName: + Description: The function name of the NHSNotifyUpdateCallback lambda + Value: !Ref NHSNotifyUpdateCallback + + NHSNotifyUpdateCallbackFunctionArn: + Description: The function ARN of the NHSNotifyUpdateCallback lambda + Value: !GetAtt NHSNotifyUpdateCallback.Arn diff --git a/SAMtemplates/main_template.yaml b/SAMtemplates/main_template.yaml index 0f9daa193c..f5770397d5 100644 --- a/SAMtemplates/main_template.yaml +++ b/SAMtemplates/main_template.yaml @@ -90,6 +90,13 @@ Parameters: Type: String Resources: + Secrets: + Type: AWS::Serverless::Application + Properties: + Location: secrets/main.yaml + Parameters: + StackName: !Ref AWS::StackName + Parameters: Type: AWS::Serverless::Application Properties: @@ -131,6 +138,8 @@ Resources: CapabilityStatementFunctionArn: !GetAtt Functions.Outputs.CapabilityStatementFunctionArn CheckPrescriptionStatusUpdatesFunctionName: !GetAtt Functions.Outputs.CheckPrescriptionStatusUpdatesFunctionName CheckPrescriptionStatusUpdatesFunctionArn: !GetAtt Functions.Outputs.CheckPrescriptionStatusUpdatesFunctionArn + NHSNotifyUpdateCallbackFunctionName: !GetAtt Functions.Outputs.NHSNotifyUpdateCallbackFunctionName + NHSNotifyUpdateCallbackFunctionArn: !GetAtt Functions.Outputs.NHSNotifyUpdateCallbackFunctionArn LogRetentionInDays: !Ref LogRetentionInDays EnableSplunk: !Ref EnableSplunk DeployCheckPrescriptionStatusUpdate: !Ref DeployCheckPrescriptionStatusUpdate @@ -142,8 +151,9 @@ Resources: Parameters: StackName: !Ref AWS::StackName PrescriptionStatusUpdatesTableName: !GetAtt Tables.Outputs.PrescriptionStatusUpdatesTableName - # PrescriptionNotificationStatesTableName: !GetAtt Tables.Outputs.PrescriptionNotificationStatesTableName + PrescriptionNotificationStatesTableName: !GetAtt Tables.Outputs.PrescriptionNotificationStatesTableName NHSNotifyPrescriptionsSQSQueueUrl: !GetAtt Messaging.Outputs.NHSNotifyPrescriptionsSQSQueueUrl + SQSSaltSecret: !GetAtt Secrets.Outputs.SQSSaltSecret EnabledSiteODSCodesParam: !GetAtt Parameters.Outputs.EnabledSiteODSCodesParameterName EnabledSystemsParam: !GetAtt Parameters.Outputs.EnabledSystemsParameterName BlockedSiteODSCodesParam: !GetAtt Parameters.Outputs.BlockedSiteODSCodesParameterName diff --git a/SAMtemplates/parameters/main.yaml b/SAMtemplates/parameters/main.yaml index 58ee61fb85..b6efc21e84 100644 --- a/SAMtemplates/parameters/main.yaml +++ b/SAMtemplates/parameters/main.yaml @@ -55,9 +55,9 @@ Resources: Value: !If - IsProd - > # Prod notification disabled - A83008 + B3J1Z - > # Non-prod - A83008 + B3J1Z Outputs: EnabledSiteODSCodesParameterName: diff --git a/SAMtemplates/secrets/main.yaml b/SAMtemplates/secrets/main.yaml new file mode 100644 index 0000000000..c4f9be58cc --- /dev/null +++ b/SAMtemplates/secrets/main.yaml @@ -0,0 +1,25 @@ +AWSTemplateFormatVersion: "2010-09-09" + +Parameters: + StackName: + Type: String + Default: none + +Resources: + SQSSaltSecret: + Type: AWS::SecretsManager::Secret + Properties: + Name: !Sub ${StackName}-SqsSaltSecret + Description: Auto-generated salt for SQS_SALT + GenerateSecretString: + SecretStringTemplate: "{}" + GenerateStringKey: salt + PasswordLength: 32 + ExcludePunctuation: true + +Outputs: + SQSSaltSecret: + Description: The ARN of the randomly generated SQS salt + Value: !Ref SQSSaltSecret + Export: + Name: !Join [":", [!Ref "StackName", "SQSSaltSecret"]] diff --git a/SAMtemplates/tables/main.yaml b/SAMtemplates/tables/main.yaml index 04a8f83820..b15e68ebb8 100644 --- a/SAMtemplates/tables/main.yaml +++ b/SAMtemplates/tables/main.yaml @@ -446,7 +446,7 @@ Resources: - !Ref "AWS::NoValue" KeySchema: - AttributeName: NHSNumber - KeyType: HASH # Partition key! + KeyType: HASH # Partition key - AttributeName: ODSCode KeyType: RANGE # Sort key BillingMode: !If diff --git a/package-lock.json b/package-lock.json index 93278d254a..f081c2a395 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "packages/cpsuLambda", "packages/checkPrescriptionStatusUpdates", "packages/nhsNotifyLambda", + "packages/nhsNotifyUpdateCallback", "packages/common/testing", "packages/common/middyErrorHandler", "packages/common/commonTypes" @@ -302,6 +303,77 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@aws-sdk/client-secrets-manager": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.821.0.tgz", + "integrity": "sha512-qsjNmliylXGr1Dod64Nh4hm9NkScJujflBjcoEWmUc5+Z9IwEovgUGLseC1KLVKIBdsVySje6LAEVvvjcWovmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.821.0", + "@aws-sdk/credential-provider-node": "3.821.0", + "@aws-sdk/middleware-host-header": "3.821.0", + "@aws-sdk/middleware-logger": "3.821.0", + "@aws-sdk/middleware-recursion-detection": "3.821.0", + "@aws-sdk/middleware-user-agent": "3.821.0", + "@aws-sdk/region-config-resolver": "3.821.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-endpoints": "3.821.0", + "@aws-sdk/util-user-agent-browser": "3.821.0", + "@aws-sdk/util-user-agent-node": "3.821.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.5.1", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.9", + "@smithy/middleware-retry": "^4.1.10", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.1", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.17", + "@smithy/util-defaults-mode-node": "^4.0.17", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.5", + "@smithy/util-utf8": "^4.0.0", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-secrets-manager/node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "license": "MIT" + }, + "node_modules/@aws-sdk/client-secrets-manager/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@aws-sdk/client-sqs": { "version": "3.823.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.823.0.tgz", @@ -9600,6 +9672,10 @@ "resolved": "packages/nhsNotifyLambda", "link": true }, + "node_modules/nhsNotifyUpdateCallback": { + "resolved": "packages/nhsNotifyUpdateCallback", + "link": true + }, "node_modules/nise": { "version": "6.1.1", "dev": true, @@ -15884,6 +15960,24 @@ "axios-mock-adapter": "^2.1.0" } }, + "packages/nhsNotifyUpdateCallback": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@aws-lambda-powertools/commons": "^2.17.0", + "@aws-lambda-powertools/logger": "^2.18.0", + "@aws-lambda-powertools/parameters": "^2.18.0", + "@aws-sdk/client-secrets-manager": "^3.821.0", + "@middy/core": "^6.2.2", + "@middy/input-output-logger": "^6.2.2", + "@nhs/fhir-middy-error-handler": "^2.1.29", + "axios": "^1.8.4" + }, + "devDependencies": { + "@PrescriptionStatusUpdate_common/testing": "^1.0.0", + "axios-mock-adapter": "^2.1.0" + } + }, "packages/sandbox": { "version": "1.0.0", "license": "MIT", diff --git a/package.json b/package.json index 62d7a6d823..9e94dc14e5 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "packages/cpsuLambda", "packages/checkPrescriptionStatusUpdates", "packages/nhsNotifyLambda", + "packages/nhsNotifyUpdateCallback", "packages/common/testing", "packages/common/middyErrorHandler", "packages/common/commonTypes" diff --git a/packages/common/commonTypes/src/index.ts b/packages/common/commonTypes/src/index.ts index 908b07fbb0..6e93302ef5 100644 --- a/packages/common/commonTypes/src/index.ts +++ b/packages/common/commonTypes/src/index.ts @@ -12,3 +12,11 @@ export interface PSUDataItem { ApplicationName: string ExpiryTime: number } + +export interface NotifyDataItem { + PatientNHSNumber: string + PharmacyODSCode: string + RequestID: string + TaskID: string + Status: string +} diff --git a/packages/nhsNotifyLambda/src/nhsNotifyLambda.ts b/packages/nhsNotifyLambda/src/nhsNotifyLambda.ts index b4175ccca1..9caa3a3920 100644 --- a/packages/nhsNotifyLambda/src/nhsNotifyLambda.ts +++ b/packages/nhsNotifyLambda/src/nhsNotifyLambda.ts @@ -6,12 +6,14 @@ import middy from "@middy/core" import inputOutputLogger from "@middy/input-output-logger" import errorHandler from "@nhs/fhir-middy-error-handler" +import {v4} from "uuid" + import { addPrescriptionMessagesToNotificationStateStore, checkCooldownForUpdate, clearCompletedSQSMessages, drainQueue, - PSUDataItemMessage + NotifyDataItemMessage } from "./utils" const logger = new Logger({serviceName: "nhsNotify"}) @@ -26,8 +28,8 @@ export const lambdaHandler = async (event: EventBridgeEvent): Pr logger.info("NHS Notify lambda triggered by scheduler", {event}) - let messages: Array - let processed: Array + let messages: Array + let processed: Array try { messages = await drainQueue(logger, 100) @@ -75,7 +77,13 @@ export const lambdaHandler = async (event: EventBridgeEvent): Pr logger.info("Fetched prescription notification messages", {count: toNotify.length, toNotify}) // TODO: Notifications request will be done here. - processed = toProcess + processed = toProcess.map((el) => { + return { + ...el, + success: true, + notifyMessageId: v4() + } + }) } catch (err) { logger.error("Error while draining SQS queue", {error: err}) diff --git a/packages/nhsNotifyLambda/src/utils.ts b/packages/nhsNotifyLambda/src/utils.ts index d45ee8c076..cdd1b98472 100644 --- a/packages/nhsNotifyLambda/src/utils.ts +++ b/packages/nhsNotifyLambda/src/utils.ts @@ -8,7 +8,7 @@ import { import {DynamoDBClient} from "@aws-sdk/client-dynamodb" import {DynamoDBDocumentClient, GetCommand, PutCommand} from "@aws-sdk/lib-dynamodb" -import {PSUDataItem} from "@PrescriptionStatusUpdate_common/commonTypes" +import {NotifyDataItem} from "@PrescriptionStatusUpdate_common/commonTypes" const TTL_DELTA = 60 * 60 * 24 * 7 // Keep records for a week @@ -36,17 +36,19 @@ function chunkArray(arr: Array, size: number): Array> { } // This is an extension of the SQS message interface, which explicitly parses the PSUDataItem -export interface PSUDataItemMessage extends Message { - PSUDataItem: PSUDataItem +export interface NotifyDataItemMessage extends Message { + PSUDataItem: NotifyDataItem + success?: boolean + notifyMessageId?: string } /** * Pulls up to `maxTotal` messages off the queue (in batches of up to 10), * logs them, and deletes them. */ -export async function drainQueue(logger: Logger, maxTotal = 100): Promise> { +export async function drainQueue(logger: Logger, maxTotal = 100): Promise> { let receivedSoFar = 0 - const allMessages: Array = [] + const allMessages: Array = [] if (!sqsUrl) { logger.error("Notifications SQS URL not configured") @@ -81,19 +83,33 @@ export async function drainQueue(logger: Logger, maxTotal = 100): Promise = Messages.map((m) => { + // flatmap causes the [] to be filtered out, since nothing is there to be flattened + const parsedMessages: Array = Messages.flatMap((m) => { if (!m.Body) { - logger.error("Failed to parse SQS message - aborting this notification processor check.", {offendingMessage: m}) - throw new Error(`Received an invalid SQS message. Message ID ${m.MessageId}`) + logger.error( + "Received an invalid SQS message (missing Body) - omitting from processing.", + {offendingMessage: m} + ) + return [] } - - const parsedBody: PSUDataItem = JSON.parse(m.Body) as PSUDataItem - - return { - ...m, - PSUDataItem: parsedBody + try { + const parsedBody: NotifyDataItem = JSON.parse(m.Body) + // This is an array of one element, which will be extracted by the flatmap + return [ + { + ...m, + PSUDataItem: parsedBody + } + ] + } catch (error) { + logger.error( + "Failed to parse SQS message body as JSON - omitting from processing.", + {offendingMessage: m, parseError: error} + ) + return [] } }) + allMessages.push(...parsedMessages) receivedSoFar += Messages.length @@ -166,13 +182,14 @@ export interface LastNotificationStateType { MessageID: string // The SQS message ID LastNotifiedPrescriptionStatus: string DeliveryStatus: string + NotifyMessageID: string // The UUID we got back from Notify for the submitted message LastNotificationRequestTimestamp: string // ISO-8601 string ExpiryTime: number // DynamoDB expiration time (UNIX timestamp) } export async function addPrescriptionMessagesToNotificationStateStore( logger: Logger, - dataArray: Array + dataArray: Array ) { if (!dynamoTable) { logger.error("DynamoDB table not configured") @@ -187,9 +204,10 @@ export async function addPrescriptionMessagesToNotificationStateStore( NHSNumber: data.PSUDataItem.PatientNHSNumber, ODSCode: data.PSUDataItem.PharmacyODSCode, RequestId: data.PSUDataItem.RequestID, - MessageID: data.MessageId!, + MessageID: data.MessageId ?? "no SQS message ID", LastNotifiedPrescriptionStatus: data.PSUDataItem.Status, - DeliveryStatus: "requested", + DeliveryStatus: data.success ? "requested" : "notify request failed", + NotifyMessageID: data.notifyMessageId ?? "", LastNotificationRequestTimestamp: new Date().toISOString(), ExpiryTime: (Math.floor(+new Date() / 1000) + TTL_DELTA) } @@ -219,7 +237,7 @@ export async function addPrescriptionMessagesToNotificationStateStore( */ export async function checkCooldownForUpdate( logger: Logger, - update: PSUDataItem, + update: NotifyDataItem, cooldownPeriod: number = 900 ): Promise { diff --git a/packages/nhsNotifyLambda/tests/testHelpers.ts b/packages/nhsNotifyLambda/tests/testHelpers.ts index 79a9583ef1..3713073a3c 100644 --- a/packages/nhsNotifyLambda/tests/testHelpers.ts +++ b/packages/nhsNotifyLambda/tests/testHelpers.ts @@ -3,7 +3,7 @@ import {jest} from "@jest/globals" import * as sqs from "@aws-sdk/client-sqs" import {PSUDataItem} from "@PrescriptionStatusUpdate_common/commonTypes" -import {PSUDataItemMessage} from "../src/utils" +import {NotifyDataItemMessage} from "../src/utils" // Similarly mock the SQS client export function mockSQSClient() { @@ -27,7 +27,7 @@ export function constructMessage(overrides: Partial = {}): sqs.Mess } } -export function constructPSUDataItemMessage(overrides: Partial = {}): PSUDataItemMessage { +export function constructPSUDataItemMessage(overrides: Partial = {}): NotifyDataItemMessage { return { ...constructMessage(), PSUDataItem: constructPSUDataItem(), diff --git a/packages/nhsNotifyLambda/tests/testNhsNotifyLambda.test.ts b/packages/nhsNotifyLambda/tests/testNhsNotifyLambda.test.ts index 52099da9f7..6357725f62 100644 --- a/packages/nhsNotifyLambda/tests/testNhsNotifyLambda.test.ts +++ b/packages/nhsNotifyLambda/tests/testNhsNotifyLambda.test.ts @@ -97,7 +97,18 @@ describe("Unit test for NHS Notify lambda handler", () => { // ensure clearCompletedSQSMessages was called with the original messages array expect(mockClearCompletedSQSMessages).toHaveBeenCalledWith( expect.any(Object), // the logger instance - [msg1, msg2] + [ + expect.objectContaining({ + ...msg1, + success: true, + notifyMessageId: expect.any(String) + }), + expect.objectContaining({ + ...msg2, + success: true, + notifyMessageId: expect.any(String) + }) + ] ) }) @@ -184,10 +195,26 @@ describe("Unit test for NHS Notify lambda handler", () => { // we should only persist & delete the fresh one expect(mockAddPrescriptionMessagesToNotificationStateStore) - .toHaveBeenCalledWith(expect.any(Object), [msgFresh]) + .toHaveBeenCalledWith(expect.any(Object), + [ + expect.objectContaining({ + ...msgFresh, + success: true, + notifyMessageId: expect.any(String) + }) + ] + ) expect(mockClearCompletedSQSMessages) - .toHaveBeenCalledWith(expect.any(Object), [msgFresh]) + .toHaveBeenCalledWith(expect.any(Object), + [ + expect.objectContaining({ + ...msgFresh, + success: true, + notifyMessageId: expect.any(String) + }) + ] + ) // and log how many were suppressed expect(mockInfo).toHaveBeenCalledWith( diff --git a/packages/nhsNotifyLambda/tests/testUtils.test.ts b/packages/nhsNotifyLambda/tests/testUtils.test.ts index 10ee6ca62d..ab37337925 100644 --- a/packages/nhsNotifyLambda/tests/testUtils.test.ts +++ b/packages/nhsNotifyLambda/tests/testUtils.test.ts @@ -104,15 +104,13 @@ describe("NHS notify lambda helper functions", () => { await expect(drainQueue(logger, 10)).rejects.toThrow("Fetch failed") }) - it("Throws an error if a message has no Body", async () => { + it("Throws no error if a message has no Body", async () => { const badMsg = constructMessage({Body: undefined}) sqsMockSend.mockImplementationOnce(() => Promise.resolve({Messages: [badMsg]})) - await expect(drainQueue(logger, 1)).rejects.toThrow( - `Received an invalid SQS message. Message ID ${badMsg.MessageId}` - ) + await drainQueue(logger, 1) expect(errorSpy).toHaveBeenCalledWith( - "Failed to parse SQS message - aborting this notification processor check.", + "Received an invalid SQS message (missing Body) - omitting from processing.", {offendingMessage: badMsg} ) }) diff --git a/packages/nhsNotifyUpdateCallback/.jest/setEnvVars.js b/packages/nhsNotifyUpdateCallback/.jest/setEnvVars.js new file mode 100644 index 0000000000..4920c105b8 --- /dev/null +++ b/packages/nhsNotifyUpdateCallback/.jest/setEnvVars.js @@ -0,0 +1,7 @@ +/* eslint-disable no-undef */ +process.env.TABLE_NAME = "dummy_table"; +process.env.APP_NAME_SECRET = "app name"; +process.env.API_KEY_SECRET = "api key"; + +process.env.APP_NAME = "app_name"; +process.env.API_KEY = "api_key"; diff --git a/packages/nhsNotifyUpdateCallback/.vscode/launch.json b/packages/nhsNotifyUpdateCallback/.vscode/launch.json new file mode 100644 index 0000000000..7c9b0b4b3a --- /dev/null +++ b/packages/nhsNotifyUpdateCallback/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "name": "vscode-jest-tests.v2", + "request": "launch", + "args": [ + "--runInBand", + "--watchAll=false", + "--testNamePattern", + "${jest.testNamePattern}", + "--runTestsByPath", + "${jest.testFile}", + "--config", + "${workspaceFolder}/jest.debug.config.ts" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "program": "${workspaceFolder}/../../node_modules/.bin/jest", + "windows": { + "program": "${workspaceFolder}/node_modules/jest/bin/jest" + }, + "env": { + "POWERTOOLS_DEV": true, + "NODE_OPTIONS": "--experimental-vm-modules" + } + } + ] +} diff --git a/packages/nhsNotifyUpdateCallback/.vscode/settings.json b/packages/nhsNotifyUpdateCallback/.vscode/settings.json new file mode 100644 index 0000000000..3501264944 --- /dev/null +++ b/packages/nhsNotifyUpdateCallback/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "jest.jestCommandLine": "/workspaces/eps-prescription-status-update-api/node_modules/.bin/jest --no-cache", + "jest.nodeEnv": { + "POWERTOOLS_DEV": true, + "NODE_OPTIONS": "--experimental-vm-modules" + } +} diff --git a/packages/nhsNotifyUpdateCallback/jest.config.ts b/packages/nhsNotifyUpdateCallback/jest.config.ts new file mode 100644 index 0000000000..7b0c4931ea --- /dev/null +++ b/packages/nhsNotifyUpdateCallback/jest.config.ts @@ -0,0 +1,17 @@ +import defaultConfig from "../../jest.default.config" +import type {JestConfigWithTsJest} from "ts-jest" + +const jestConfig: JestConfigWithTsJest = { + ...defaultConfig, + rootDir: "./", + setupFiles: ["/.jest/setEnvVars.js"], + coveragePathIgnorePatterns: ["/tests/"], + coverageReporters: [ + "clover", + "json", + "text", + ["lcov", {projectRoot: "../../"}] + ] +} + +export default jestConfig diff --git a/packages/nhsNotifyUpdateCallback/jest.debug.config.ts b/packages/nhsNotifyUpdateCallback/jest.debug.config.ts new file mode 100644 index 0000000000..a306273831 --- /dev/null +++ b/packages/nhsNotifyUpdateCallback/jest.debug.config.ts @@ -0,0 +1,9 @@ +import config from "./jest.config" +import type {JestConfigWithTsJest} from "ts-jest" + +const debugConfig: JestConfigWithTsJest = { + ...config, + "preset": "ts-jest" +} + +export default debugConfig diff --git a/packages/nhsNotifyUpdateCallback/package.json b/packages/nhsNotifyUpdateCallback/package.json new file mode 100644 index 0000000000..adcc084fc9 --- /dev/null +++ b/packages/nhsNotifyUpdateCallback/package.json @@ -0,0 +1,30 @@ +{ + "name": "nhsNotifyUpdateCallback", + "version": "1.0.0", + "description": "A lambda that processes notification update callbacks from NHS notify", + "main": "lambdaHandler.js", + "author": "NHS Digital", + "license": "MIT", + "type": "module", + "scripts": { + "unit": "POWERTOOLS_DEV=true NODE_OPTIONS=--experimental-vm-modules jest --no-cache --coverage", + "lint": "eslint --max-warnings 0 --fix --config ../../eslint.config.mjs .", + "compile": "tsc", + "test": "npm run compile && npm run unit", + "check-licenses": "license-checker --failOn GPL --failOn LGPL --start ../.." + }, + "dependencies": { + "@aws-lambda-powertools/commons": "^2.17.0", + "@aws-lambda-powertools/logger": "^2.18.0", + "@aws-lambda-powertools/parameters": "^2.18.0", + "@aws-sdk/client-secrets-manager": "^3.821.0", + "@middy/core": "^6.2.2", + "@middy/input-output-logger": "^6.2.2", + "@nhs/fhir-middy-error-handler": "^2.1.29", + "axios": "^1.8.4" + }, + "devDependencies": { + "@PrescriptionStatusUpdate_common/testing": "^1.0.0", + "axios-mock-adapter": "^2.1.0" + } +} diff --git a/packages/nhsNotifyUpdateCallback/src/helpers.ts b/packages/nhsNotifyUpdateCallback/src/helpers.ts new file mode 100644 index 0000000000..73e0bbcf8d --- /dev/null +++ b/packages/nhsNotifyUpdateCallback/src/helpers.ts @@ -0,0 +1,219 @@ +import {APIGatewayProxyEvent} from "aws-lambda" +import {Logger} from "@aws-lambda-powertools/logger" + +import {DynamoDBClient} from "@aws-sdk/client-dynamodb" +import {DynamoDBDocumentClient, UpdateCommand, QueryCommand} from "@aws-sdk/lib-dynamodb" +import {getSecret} from "@aws-lambda-powertools/parameters/secrets" + +import {createHmac, timingSafeEqual} from "crypto" + +import {MessageStatusResponse} from "./types" + +const APP_NAME_SECRET = process.env.APP_NAME_SECRET +const API_KEY_SECRET = process.env.API_KEY_SECRET + +// Actual secret values +let APP_NAME: string | undefined +let API_KEY: string | undefined + +// TTL is one week in seconds +const TTL_DELTA = 60 * 60 * 24 * 7 + +const dynamoTable = process.env.TABLE_NAME + +const dynamo = new DynamoDBClient({region: process.env.AWS_REGION}) +const docClient = DynamoDBDocumentClient.from(dynamo) + +export function response(statusCode: number, body: unknown = {}) { + return { + statusCode, + body: JSON.stringify(body) + } +} + +/** + * Fetches all secret values from the AWS Secrets Manager + */ +export async function fetchSecrets(logger: Logger): Promise { + if (!APP_NAME_SECRET) { + throw new Error("APP_NAME_SECRET environment variable is not set.") + } + if (!API_KEY_SECRET) { + throw new Error("API_KEY_SECRET environment variable is not set.") + } + + // Fetch both secrets in parallel + const [appNameValue, apiKeyValue] = await Promise.all([ + getSecret(APP_NAME_SECRET), + getSecret(API_KEY_SECRET) + ]) + + if ( + appNameValue === undefined + || apiKeyValue === undefined + || appNameValue instanceof Uint8Array + || apiKeyValue instanceof Uint8Array + || !appNameValue?.toString() + || !apiKeyValue?.toString() + ) { + throw new Error("Failed to get secret values from the AWS secret manager") + } + + APP_NAME = appNameValue.toString() + API_KEY = apiKeyValue.toString() + + // Check again to catch empty strings + if (!appNameValue || !apiKeyValue) { + throw new Error("Failed to get secret values from the AWS secret manager") + } + + logger.info("Fetched secrets OK") +} + +/** + * Checks the incoming NHS Notify request signature. + * If it's okay, returns undefined. + * If it's not okay, it returns the error response object. + */ +export async function checkSignature(logger: Logger, event: APIGatewayProxyEvent) { + try { + await fetchSecrets(logger) + } catch (err) { + logger.error("Failed to get secret values", {err}) + return response(500, "Internal Server Error") + } + + const signature = event.headers["x-hmac-sha256-signature"] + if (!signature) { + logger.error("No x-hmac-sha256-signature header given") + return response(401, {message: "No x-hmac-sha256-signature given"}) + } + + const givenApiKey = event.headers["x-api-key"] + if (!givenApiKey) { + logger.error("No x-api-key header given") + return response(401, {message: "No x-api-key header given"}) + } + + // Compute the HMAC-SHA256 hash of the combination of the request body and the secret value + const secretValue = `${APP_NAME}.${API_KEY}` + const payload = event.body ?? "" + + // compare hashes as Buffers, rather than hex + const expectedSigBuf = createHmac("sha256", secretValue) + .update(payload, "utf8") + .digest() // Buffer + + // Convert the incoming hex signature into a Buffer + const givenSigBuf = Buffer.from(signature, "hex") + + // Must be same length for timingSafeEqual + if (givenSigBuf.length !== expectedSigBuf.length || + !timingSafeEqual(expectedSigBuf, givenSigBuf)) { + logger.error("Incorrect signature given", { + expectedSignature: expectedSigBuf.toString("hex"), + givenSignature: signature + }) + return response(403, {message: "Incorrect signature"}) + } + + return undefined +} + +/** + * For each incoming NHS Notify message-status callback, + * find the matching record in DynamoDB by NotifyMessageID, + * and update it with the new delivery status, timestamp, and channels. + * Do that all in parallel. + */ +export async function updateNotificationsTable( + logger: Logger, + bodyData: MessageStatusResponse +): Promise { + // For each callback resource, return a promise + const callbackPromises = bodyData.data.map(async (resource) => { + const {messageId, messageStatus, timestamp} = resource.attributes + + // Query matching records + let queryResult + try { + queryResult = await docClient.send(new QueryCommand({ + TableName: dynamoTable, + IndexName: "NotifyMessageIDIndex", + KeyConditionExpression: "NotifyMessageID = :nm", + ExpressionAttributeValues: { + ":nm": messageId + } + })) + } catch (error) { + logger.error("Error querying by NotifyMessageID", {messageId, error}) + throw error + } + + const items = queryResult.Items ?? [] + if (items.length === 0) { + logger.warn("No matching record found for NotifyMessageID. Counting this as a successful update.", {messageId}) + return + } + if (items.length !== bodyData.data.length) { + logger.warn("Not every received message update had a pre-existing record in the table.", + { + requestItemsLength: bodyData.data.length, + tableQueryResultsLength: items.length + } + ) + // Elements without pre-existing records should, in theory, have a new one created. + // But we don't have enough information to do that so we ignore that edge case and + // count it as a success. + } + + const newExpiry = Math.floor(Date.now() / 1000) + TTL_DELTA + + // For each match, update in parallel + const updatePromises = items.map(async item => { + const key = { + NHSNumber: item.NHSNumber, + ODSCode: item.ODSCode + } + try { + await docClient.send(new UpdateCommand({ + TableName: dynamoTable, + Key: key, + UpdateExpression: [ + "SET DeliveryStatus = :ds", + " , LastNotificationRequestTimestamp = :ts", + " , ExpiryTime = :et" + ].join(""), + ExpressionAttributeValues: { + ":ds": messageStatus, + ":ts": timestamp, + ":et": newExpiry + } + })) + logger.info( + "Updated notification state", + { + NotifyMessageID: item.NotifyMessageID, + newStatus: messageStatus, + newTimestamp: timestamp, + newExpiryTime: newExpiry + } + ) + } catch (err) { + logger.error( + "Failed to update notification state", + { + NotifyMessageID: item.NotifyMessageID, + error: err + } + ) + } + }) + + // wait for all updates for this callback + await Promise.all(updatePromises) + }) + + // wait for all callbacks to be processed + await Promise.all(callbackPromises) +} diff --git a/packages/nhsNotifyUpdateCallback/src/lambdaHandler.ts b/packages/nhsNotifyUpdateCallback/src/lambdaHandler.ts new file mode 100644 index 0000000000..f25deff056 --- /dev/null +++ b/packages/nhsNotifyUpdateCallback/src/lambdaHandler.ts @@ -0,0 +1,68 @@ +import {APIGatewayProxyEvent, APIGatewayProxyResult} from "aws-lambda" + +import {Logger} from "@aws-lambda-powertools/logger" +import {injectLambdaContext} from "@aws-lambda-powertools/logger/middleware" + +import middy from "@middy/core" +import inputOutputLogger from "@middy/input-output-logger" +import httpHeaderNormalizer from "@middy/http-header-normalizer" + +import errorHandler from "@nhs/fhir-middy-error-handler" + +import {MessageStatusResponse} from "./types" +import {checkSignature, response, updateNotificationsTable} from "./helpers" + +export const logger = new Logger({serviceName: "nhsNotifyUpdateCallback"}) + +const lambdaHandler = async (event: APIGatewayProxyEvent): Promise => { + logger.appendKeys({ + "x-correlation-id": event.headers["x-correlation-id"], + "apigw-request-id": event.headers["apigw-request-id"], + "x-request-id": event.headers["x-request-id"] + }) + logger.info("Lambda called with this event", {event}) + + // Require a request ID + if (!event.headers["x-request-id"]) return response(400, {message: "No x-request-id given"}) + + // Check the request signature + const isErr = await checkSignature(logger, event) + if (isErr) return isErr + logger.info("Signature OK!") + + // Parse out the request body + if (!event.body) return response(400, {message: "No request body given"}) + let payload: MessageStatusResponse + try { + payload = JSON.parse(event.body) + logger.info("Payload parsed", {payload}) + } catch (error) { + logger.error("Failed to parse payload", {error, payload: event.body}) + return response(400, {message: "Request body failed to parse"}) + } + + try { + await updateNotificationsTable(logger, payload) + } catch (error) { + logger.info("Failed to push updates to the notification state table", {error}) + return response(500, {message: "Failed to update the notification state table"}) + } + + // All's well that ends well + return { + statusCode: 202, + body: "OK" + } +} + +export const handler = middy(lambdaHandler) + .use(injectLambdaContext(logger, {clearState: true})) + .use(httpHeaderNormalizer()) + .use( + inputOutputLogger({ + logger: (request) => { + logger.info(request) + } + }) + ) + .use(errorHandler({logger: logger})) diff --git a/packages/nhsNotifyUpdateCallback/src/types.ts b/packages/nhsNotifyUpdateCallback/src/types.ts new file mode 100644 index 0000000000..30699a17ab --- /dev/null +++ b/packages/nhsNotifyUpdateCallback/src/types.ts @@ -0,0 +1,79 @@ +// Enums +export type MessageStatus = + | "created" + | "pending_enrichment" + | "enriched" + | "sending" + | "delivered" + | "failed"; + +export type ChannelType = + | "nhsapp" + | "email" + | "sms" + | "letter"; + +export type ChannelStatus = + | "created" + | "sending" + | "delivered" + | "failed" + | "skipped"; + +// Callback return schema +export interface Channel { + /** The communication type of this channel */ + type: ChannelType; + /** Current status of this channel */ + channelStatus: ChannelStatus; +} + +export interface RoutingPlan { + /** Identifier for the routing plan */ + id: string; + /** Name of the routing plan */ + name: string; + /** Specific version of the routing plan */ + version: string; + /** Creation date of the routing plan */ + createdDate: string; +} + +export interface MessageStatusAttributes { + /** Unique identifier for the message */ + messageId: string; + /** Original reference supplied for the message */ + messageReference: string; + /** Aggregate status across all channels */ + messageStatus: MessageStatus; + /** Extra information about the message status, if any */ + messageStatusDescription?: string; + /** List of channels attempted for delivery */ + channels: Array; + /** Timestamp of the callback event */ + timestamp: string; + /** Routing plan details */ + routingPlan: RoutingPlan; +} + +export interface MessageStatusResource { + /** Always "MessageStatus" */ + type: "MessageStatus"; + attributes: MessageStatusAttributes; + links: { + /** URL to retrieve the overarching message status */ + message: string; + }; + meta: { + /** Key to deduplicate retried requests */ + idempotencyKey: string; + }; +} + +export interface MessageStatusResponse { + /** + * Array of MessageStatus resources. + * Must contain at least one element. + */ + data: Array; +} diff --git a/packages/nhsNotifyUpdateCallback/tests/testHelpers.test.ts b/packages/nhsNotifyUpdateCallback/tests/testHelpers.test.ts new file mode 100644 index 0000000000..7d9433494a --- /dev/null +++ b/packages/nhsNotifyUpdateCallback/tests/testHelpers.test.ts @@ -0,0 +1,343 @@ +import { + jest, + describe, + it, + beforeEach, + afterEach, + expect +} from "@jest/globals" +import {createHmac} from "crypto" + +// Mock the getSecret call +const mockGetSecret = jest.fn((secretName: string) => { + if (secretName === process.env.APP_NAME_SECRET) { + return Promise.resolve(process.env.APP_NAME) + } + if (secretName === process.env.API_KEY_SECRET) { + return Promise.resolve(process.env.API_KEY) + } + return Promise.reject(new Error("Unexpected secret")) +}) +jest.unstable_mockModule("@aws-lambda-powertools/parameters/secrets", async () => ({ + __esModule: true, + getSecret: mockGetSecret +})) + +import {DynamoDBDocumentClient, QueryCommand, UpdateCommand} from "@aws-sdk/lib-dynamodb" +import {Logger} from "@aws-lambda-powertools/logger" +import {MessageStatusResponse} from "../src/types" +import {generateMockEvent, generateMockMessageStatusResponse} from "./utilities" + +const { + response, + checkSignature, + updateNotificationsTable +} = await import("../src/helpers") + +const ORIGINAL_ENV = {...process.env} + +describe("helpers.ts", () => { + let sendSpy: jest.SpiedFunction + + beforeEach(() => { + jest.resetModules() + jest.clearAllMocks() + + // Spy on all docClient.send calls + sendSpy = jest.spyOn(DynamoDBDocumentClient.prototype, "send") + + // Freeze time so TTL is predictable + jest.spyOn(Date, "now").mockReturnValue(100_000_000) // ms + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe("response()", () => { + it("serialises status and body", () => { + const r = response(418, {hello: "world"}) + expect(r).toEqual({ + statusCode: 418, + body: JSON.stringify({hello: "world"}) + }) + }) + }) + + describe("checkSignature()", () => { + let logger: Logger + let validHeaders: { "x-request-id": string; "x-api-key": string; "x-hmac-sha256-signature": string } + beforeEach(() => { + logger = new Logger({serviceName: "nhsNotifyUpdateCallback"}) + validHeaders = { + "x-request-id": "requestid", + "x-api-key": "api-key", + "x-hmac-sha256-signature": "deadbeef" + } + }) + + it("401 when missing signature header", async () => { + const ev = generateMockEvent("{}", {"x-api-key": "foobar", "x-request-id": "rid"}) + const resp = await checkSignature(logger, ev) + expect(resp).toEqual({ + statusCode: 401, + body: JSON.stringify({message: "No x-hmac-sha256-signature given"}) + }) + }) + + it("401 when missing API key header", async () => { + const ev = generateMockEvent("{}", {"x-hmac-sha256-signature": "foobar", "x-request-id": "rid"}) + const resp = await checkSignature(logger, ev) + + expect(resp).toEqual({ + statusCode: 401, + body: JSON.stringify({message: "No x-api-key header given"}) + }) + }) + + it("403 when signature hex is malformed", async () => { + const headers = { + ...validHeaders, + "x-hmac-sha256-signature": "not a hex string!@!#zzz" + } + const ev = generateMockEvent(JSON.stringify({message: "blah blah blah"}), headers) + const resp = await checkSignature(logger, ev) + + expect(resp).toEqual({ + statusCode: 403, + body: JSON.stringify({message: "Incorrect signature"}) + }) + }) + + it("403 when signature does not match HMAC", async () => { + const payload = "payload" + const wrongSig = createHmac( + "sha256", + `${process.env.APP_NAME}.${process.env.API_KEY}` + ) + .update("different", "utf8") + .digest("hex") + + const ev = generateMockEvent(payload, { + ...validHeaders, + "x-hmac-sha256-signature": wrongSig + }) + const resp = await checkSignature(logger, ev) + + expect(resp).toEqual({ + statusCode: 403, + body: JSON.stringify({message: "Incorrect signature"}) + }) + }) + + it("returns undefined when signature is valid", async () => { + const payload = "hi there" + const secret = `${process.env.APP_NAME}.${process.env.API_KEY}` + const goodSig = createHmac("sha256", secret) + .update(payload, "utf8") + .digest("hex") + + const ev = generateMockEvent(payload, { + ...validHeaders, + "x-hmac-sha256-signature": goodSig + }) + const resp = await checkSignature(logger, ev) + expect(resp).toBeUndefined() + }) + }) + + describe("updateNotificationsTable()", () => { + let logger: Logger + beforeEach(() => { + logger = new Logger({serviceName: "nhsNotifyUpdateCallback"}) + jest.spyOn(logger, "error") + jest.spyOn(logger, "warn") + jest.spyOn(logger, "info") + + jest.resetModules() + jest.clearAllMocks() + }) + + it("skips update when no matching record found", async () => { + // QueryCommand returns no items + sendSpy.mockImplementationOnce(() => Promise.resolve({Items: []})) + + const responsePayload: MessageStatusResponse = generateMockMessageStatusResponse() + await updateNotificationsTable(logger, responsePayload) + + // Only QueryCommand should be called + expect(sendSpy).toHaveBeenCalledTimes(1) + expect(sendSpy).toHaveBeenCalledWith(expect.any(QueryCommand)) + // Warning logged + expect(logger.warn).toHaveBeenCalledWith( + "No matching record found for NotifyMessageID. Counting this as a successful update.", + expect.objectContaining({messageId: responsePayload.data[0].attributes.messageId}) + ) + }) + + it("updates records when matching items found", async () => { + const overrideTimestamp = "2025-01-01T00:00:00.000Z" + const mockResponse = generateMockMessageStatusResponse([ + { + attributes: { + messageId: "msg-123", + messageStatus: "delivered", + timestamp: overrideTimestamp + } + } + ]) + const mockItem = { + NHSNumber: "NHS123", + ODSCode: "ODS1", + NotifyMessageID: "msg-123" + } + // First call: QueryCommand + // Subsequent calls: UpdateCommand + sendSpy.mockImplementation((cmd) => { + if (cmd instanceof QueryCommand) { + return Promise.resolve({Items: [mockItem]}) + } + if (cmd instanceof UpdateCommand) { + return Promise.resolve({}) + } + return Promise.resolve({}) + }) + + await updateNotificationsTable(logger, mockResponse) + + expect(sendSpy).toHaveBeenCalledWith(expect.any(QueryCommand)) + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + TableName: process.env.TABLE_NAME, + Key: {NHSNumber: mockItem.NHSNumber, ODSCode: mockItem.ODSCode}, + ExpressionAttributeValues: { + ":ds": mockResponse.data[0].attributes.messageStatus, + ":ts": overrideTimestamp, + ":et": Math.floor(100_000_000 / 1000) + 60 * 60 * 24 * 7 + } + }) + }) + ) + expect(logger.info).toHaveBeenCalledWith( + "Updated notification state", + expect.objectContaining({ + NotifyMessageID: mockItem.NotifyMessageID, + newStatus: mockResponse.data[0].attributes.messageStatus, + newTimestamp: overrideTimestamp, + newExpiryTime: Math.floor(100_000_000 / 1000) + 60 * 60 * 24 * 7 + }) + ) + }) + + it("warns when not every received message update had a pre-existing record in the table", async () => { + const mockResponse: MessageStatusResponse = generateMockMessageStatusResponse( + [ + {attributes: {messageId: "msg-1"}}, + {attributes: {messageId: "msg-2"}} + ], + 2 + ) + const mockItem = { + NHSNumber: "NHS123", + ODSCode: "ODS1", + NotifyMessageID: "msg-1" + } + // QueryCommand returns only one item for both resources + sendSpy.mockImplementation(() => Promise.resolve({Items: [mockItem]})) + + await updateNotificationsTable(logger, mockResponse) + + // Warning logged for uneven matching + expect(logger.warn).toHaveBeenCalledWith( + "Not every received message update had a pre-existing record in the table.", + expect.objectContaining({ + requestItemsLength: mockResponse.data.length, + tableQueryResultsLength: 1 + }) + ) + }) + + it("logs error and continues when query fails", async () => { + // Simulate query failure + const awsError = new Error("Failed") + sendSpy.mockImplementation(() => Promise.reject(awsError)) + + const responsePayload: MessageStatusResponse = generateMockMessageStatusResponse() + await expect(updateNotificationsTable(logger, responsePayload)).rejects.toThrow(awsError) + + expect(logger.error).toHaveBeenCalledWith( + "Error querying by NotifyMessageID", + expect.objectContaining({ + messageId: responsePayload.data[0].attributes.messageId, + error: awsError + }) + ) + }) + + it("logs error and continues when update fails", async () => { + const mockResponse: MessageStatusResponse = generateMockMessageStatusResponse() + const mockItem = { + NHSNumber: "NHS123", + ODSCode: "ODS1", + NotifyMessageID: mockResponse.data[0].attributes.messageId + } + // Query succeeds + sendSpy.mockImplementationOnce(() => Promise.resolve({Items: [mockItem]})) + // Update fails + const awsError = new Error("Failed") + sendSpy.mockImplementationOnce(() => Promise.reject(awsError)) + + await updateNotificationsTable(logger, mockResponse) + + expect(logger.error).toHaveBeenCalledWith( + "Failed to update notification state", + expect.objectContaining({ + NotifyMessageID: mockItem.NotifyMessageID, + error: awsError + }) + ) + }) + }) + + describe("fetchSecrets()", () => { + let logger: Logger + beforeEach(() => { + jest.resetModules() + jest.clearAllMocks() + process.env = {...ORIGINAL_ENV} + logger = new Logger({serviceName: "nhsNotifyUpdateCallback"}) + }) + + it("throws if APP_NAME_SECRET env var is not set", async () => { + delete process.env.APP_NAME_SECRET + + const {fetchSecrets: fn} = await import("../src/helpers") + await expect(fn(logger)).rejects.toThrow("APP_NAME_SECRET environment variable is not set.") + }) + + it("throws if API_KEY_SECRET env var is not set", async () => { + delete process.env.API_KEY_SECRET + + const {fetchSecrets: fn} = await import("../src/helpers") + await expect(fn(logger)).rejects.toThrow("API_KEY_SECRET environment variable is not set.") + }) + + it("throws if getting either secret returns a falsy value", async () => { + process.env.APP_NAME = "" + + const {fetchSecrets: fn} = await import("../src/helpers") + await expect(fn(logger)).rejects.toThrow( + "Failed to get secret values from the AWS secret manager" + ) + }) + + it("fetches both secrets successfully", async () => { + const {fetchSecrets: fn} = await import("../src/helpers") + await expect(fn(logger)).resolves.toBeUndefined() + + expect(mockGetSecret).toHaveBeenCalledWith(process.env.APP_NAME_SECRET) + expect(mockGetSecret).toHaveBeenCalledWith(process.env.API_KEY_SECRET) + }) + }) +}) diff --git a/packages/nhsNotifyUpdateCallback/tests/testNhsNotifyCallbackLambda.test.ts b/packages/nhsNotifyUpdateCallback/tests/testNhsNotifyCallbackLambda.test.ts new file mode 100644 index 0000000000..9174adba61 --- /dev/null +++ b/packages/nhsNotifyUpdateCallback/tests/testNhsNotifyCallbackLambda.test.ts @@ -0,0 +1,126 @@ +import { + jest, + describe, + it, + beforeAll, + afterEach +} from "@jest/globals" + +import {generateMockEvent} from "./utilities" + +const mockCheckSignature = jest.fn() +const mockResponse = jest.fn() +const mockUpdateNotificationsTable = jest.fn() +jest.unstable_mockModule( + "../src/helpers", + async () => ({ + __esModule: true, + checkSignature: mockCheckSignature, + response: mockResponse, + updateNotificationsTable: mockUpdateNotificationsTable + }) +) + +let handler: typeof import("../src/lambdaHandler").handler + +beforeAll(async () => { + ({handler} = await import("../src/lambdaHandler")) +}) + +const ORIGINAL_ENV = {...process.env} + +describe("NHS Notify update callback lambda handler", () => { + afterEach(() => { + process.env = {...ORIGINAL_ENV} + jest.clearAllMocks() + jest.restoreAllMocks() + }) + + it("returns 400 if no x-request-id header", async () => { + const event = generateMockEvent({foo: "bar"}) + delete event.headers["x-request-id"] + const bad = {statusCode: 400, body: JSON.stringify({message: "No x-request-id given"})} + mockResponse.mockImplementation(() => bad) + + const result = await handler(event, {}) + + expect(mockResponse).toHaveBeenCalledWith(400, {message: "No x-request-id given"}) + expect(result).toBe(bad) + expect(mockCheckSignature).not.toHaveBeenCalled() + }) + + it("returns signature error if checkSignature returns an error response", async () => { + const event = generateMockEvent({foo: "bar"}) + event.headers["x-request-id"] = "abc" + const sigError = {statusCode: 401, body: "bad sig"} + mockCheckSignature.mockImplementation(() => sigError) + + const result = await handler(event, {}) + + expect(mockCheckSignature).toHaveBeenCalled() + expect(result).toBe(sigError) + }) + + it("returns 400 if body is missing", async () => { + const event = generateMockEvent({foo: "bar"}) + event.headers["x-request-id"] = "abc" + mockCheckSignature.mockImplementation(() => undefined) + event.body = null + const bad = {statusCode: 400, body: JSON.stringify({message: "No request body given"})} + mockResponse.mockImplementation(() => bad) + + const result = await handler(event, {}) + + expect(mockResponse).toHaveBeenCalledWith(400, {message: "No request body given"}) + expect(result).toBe(bad) + }) + + it("returns 400 if body is invalid JSON", async () => { + const event = generateMockEvent({foo: "bar"}) + event.headers["x-request-id"] = "abc" + mockCheckSignature.mockImplementation(() => undefined) + event.body = "not-json" + const bad = {statusCode: 400, body: JSON.stringify({message: "Request body failed to parse"})} + mockResponse.mockImplementation(() => bad) + + const result = await handler(event, {}) + + expect(mockResponse).toHaveBeenCalledWith(400, {message: "Request body failed to parse"}) + expect(result).toBe(bad) + }) + + it("returns 500 if updateNotificationsTable throws", async () => { + const payload = {status: "foo"} + const event = generateMockEvent(payload) + event.headers["x-request-id"] = "abc" + mockCheckSignature.mockImplementation(() => undefined) + event.body = JSON.stringify(payload) + mockUpdateNotificationsTable.mockImplementation(() => Promise.reject(new Error("dynamo fail"))) + const errResp = { + statusCode: 500, + body: JSON.stringify({message: "Failed to update the notification state table"}) + } + mockResponse.mockImplementation(() => errResp) + + const result = await handler(event, {}) + + expect(mockResponse) + .toHaveBeenCalledWith(500, {message: "Failed to update the notification state table"}) + expect(result).toBe(errResp) + }) + + it("returns 202 and 'OK' when everything succeeds", async () => { + const payload = {status: "ok"} + const event = generateMockEvent(payload) + event.headers["x-request-id"] = "abc" + mockCheckSignature.mockImplementation(() => undefined) + event.body = JSON.stringify(payload) + mockUpdateNotificationsTable.mockImplementation(() => Promise.resolve()) + + const result = await handler(event, {}) + + expect(mockCheckSignature).toHaveBeenCalledWith(expect.any(Object), event) + expect(mockUpdateNotificationsTable).toHaveBeenCalledWith(expect.any(Object), payload) + expect(result).toEqual({statusCode: 202, body: "OK"}) + }) +}) diff --git a/packages/nhsNotifyUpdateCallback/tests/utilities.ts b/packages/nhsNotifyUpdateCallback/tests/utilities.ts new file mode 100644 index 0000000000..f672dfe723 --- /dev/null +++ b/packages/nhsNotifyUpdateCallback/tests/utilities.ts @@ -0,0 +1,111 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import {APIGatewayProxyEvent} from "aws-lambda" +import { + Channel, + MessageStatusResource, + MessageStatusResponse, + RoutingPlan +} from "../src/types" + +export const X_REQUEST_ID = "43313002-debb-49e3-85fa-34812c150242" +export const APPLICATION_NAME = "test-app" + +const DEFAULT_HEADERS = {"x-request-id": X_REQUEST_ID, "attribute-name": APPLICATION_NAME} + +export const generateMockEvent = (body: any = {}, headers: any = {}): APIGatewayProxyEvent => { + const requestHeaders = { + ...headers, + ...DEFAULT_HEADERS + } + + return { + body: body, + headers: requestHeaders, + multiValueHeaders: {}, + httpMethod: "POST", + isBase64Encoded: false, + path: "/callback", + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: {} as any, + resource: "", + pathParameters: null + } +} + +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] +} + +/** + * Generates a mock MessageStatusResponse for testing, with optional deep overrides. + * @param dataOverrides Array of partial resource overrides to apply (one per data item). + * If you pass fewer overrides than numData, the rest will be empty. + * @param numData Number of items to generate. Defaults to 1. + */ +export function generateMockMessageStatusResponse( + dataOverrides: Array> = [], + numData: number = 1 +): MessageStatusResponse { + const defaultRoutingPlan: RoutingPlan = { + id: "plan-1", + name: "Default Plan", + version: "v1", + createdDate: new Date().toISOString() + } + + const defaultChannels: Array = [ + {type: "nhsapp", channelStatus: "delivered"} + ] + + const defaultResource: MessageStatusResource = { + type: "MessageStatus", + attributes: { + messageId: "msg-123", + messageReference: "ref-123", + messageStatus: "delivered", + channels: defaultChannels, + timestamp: new Date().toISOString(), + routingPlan: defaultRoutingPlan + }, + links: {message: "/messages/msg-123"}, + meta: {idempotencyKey: "idem-123"} + } + + // Build an array of exactly numData overrides, using {} when none provided + const overrides = Array.from({length: numData}, (_, i) => dataOverrides[i] ?? {}) + + const mergedData = overrides.map((override) => { + const attrsOverride = override.attributes ?? {} + + // Deep-merge channels + const mergedChannels: Array = Array.isArray(attrsOverride.channels) + ? attrsOverride.channels.map((ch) => ({ + ...defaultChannels[0], + ...(ch as DeepPartial) + })) + : defaultChannels + + const data: MessageStatusResource = { + ...defaultResource, + ...override, // top‐level overrides + attributes: { + ...defaultResource.attributes, + ...attrsOverride, + routingPlan: { + ...defaultRoutingPlan, + ...(attrsOverride.routingPlan as DeepPartial ?? {}) + }, + channels: mergedChannels + }, + links: {...defaultResource.links, ...(override.links ?? {})}, + meta: {...defaultResource.meta, ...(override.meta ?? {})} + } + + return data + }) + + return {data: mergedData} +} diff --git a/packages/nhsNotifyUpdateCallback/tsconfig.json b/packages/nhsNotifyUpdateCallback/tsconfig.json new file mode 100644 index 0000000000..20eac33d90 --- /dev/null +++ b/packages/nhsNotifyUpdateCallback/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.defaults.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "lib" + }, + "references": [], + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/specification/eps-prescription-status-update-api.yaml b/packages/specification/eps-prescription-status-update-api.yaml index fd0d1ff7d0..60dd5d7d6d 100755 --- a/packages/specification/eps-prescription-status-update-api.yaml +++ b/packages/specification/eps-prescription-status-update-api.yaml @@ -292,11 +292,57 @@ paths: security: - app-level3: [] + /notification-delivery-status-callback: + post: + operationId: prescription-status-update-notification-delivery-status-callback + summary: "Internal: Prescription status update notification delivery status callback" + description: | + ## This endpoint is for internal usage only + ## Overview + This endpoint provides a callback for the NHS notifications service to update the + Prescription Status Update API on the delivery status of requested notifications. + + Please refer to [their documentation](https://digital.nhs.uk/developer/api-catalogue/nhs-notify#post-/%3Cclient-provided-message-status-URI%3E) for more detail on the request schema. + parameters: + - in: header + name: x-hmac-sha256-signature + required: true + description: | + Contains a HMAC-SHA256 signature of the request body using a pre-agreed secret. + schema: + type: string + example: 9ee8c6aab877a97600e5c0cd8419f52d3dcdc45002e35220873d11123db6486f + responses: + "202": + description: Successfully updated notification delivery status + 4XX: + description: | + An error occurred as follows: + | HTTP status | Error code | Description | + | ----------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | + | 401 | `unauthorised` | Missing or invalid OAuth 2.0 bearer token in request | + | 403 | `forbidden` | Supplied signature was incorrect. | + | 429 | `throttled` | You have exceeded your application's [rate limit](https://digital.nhs.uk/developer/guides-and-documentation/reference-guide#rate-limits). | + + security: + - app-level0: [] + components: securitySchemes: + app-level0: + $ref: https://proxygen.prod.api.platform.nhs.uk/components/securitySchemes/app-level0 app-level3: $ref: https://proxygen.prod.api.platform.nhs.uk/components/securitySchemes/app-level3 parameters: + ApiKey: + in: header + name: x-api-key + required: true + description: | + Contains the pre-agreed API key. + schema: + type: string + example: 0bb04a0e-d005-42dd-8993-dacf37410a12 BearerAuthorisation: in: header name: Authorization diff --git a/packages/updatePrescriptionStatus/.jest/setEnvVars.js b/packages/updatePrescriptionStatus/.jest/setEnvVars.js index 418a51377c..6c6414293d 100644 --- a/packages/updatePrescriptionStatus/.jest/setEnvVars.js +++ b/packages/updatePrescriptionStatus/.jest/setEnvVars.js @@ -4,4 +4,4 @@ process.env.AWS_REGION = "eu-west-2"; process.env.SQS_SALT = "the quick brown fox something something" process.env.ENABLED_SITE_ODS_CODES = "FA565" process.env.ENABLED_SYSTEMS = "Internal Test System,Apotec Ltd - Apotec CRM - Production,CrxPatientApp,nhsPrescriptionApp,Titan PSU Prod" -process.env.BLOCKED_SITE_ODS_CODES = "A83008" +process.env.BLOCKED_SITE_ODS_CODES = "B3J1Z" diff --git a/packages/updatePrescriptionStatus/src/updatePrescriptionStatus.ts b/packages/updatePrescriptionStatus/src/updatePrescriptionStatus.ts index 0bcfa2a1b0..4dd38215d9 100644 --- a/packages/updatePrescriptionStatus/src/updatePrescriptionStatus.ts +++ b/packages/updatePrescriptionStatus/src/updatePrescriptionStatus.ts @@ -223,7 +223,7 @@ export function handleTransactionCancelledException( const conflictedEntry = conflictDuplicate(taskId) const index = responseEntries.findIndex((entry) => { - const entryTaskId = entry.response?.location?.split("/").pop() || entry.fullUrl?.split(":").pop() + const entryTaskId = entry.response?.location?.split("/").pop() ?? entry.fullUrl?.split(":").pop() return entryTaskId === taskId }) diff --git a/packages/updatePrescriptionStatus/src/utils/sqsClient.ts b/packages/updatePrescriptionStatus/src/utils/sqsClient.ts index 325dd924bd..d549dae0b4 100644 --- a/packages/updatePrescriptionStatus/src/utils/sqsClient.ts +++ b/packages/updatePrescriptionStatus/src/utils/sqsClient.ts @@ -3,7 +3,7 @@ import {SQSClient, SendMessageBatchCommand} from "@aws-sdk/client-sqs" import {createHmac} from "crypto" -import {PSUDataItem} from "@PrescriptionStatusUpdate_common/commonTypes" +import {PSUDataItem, NotifyDataItem} from "@PrescriptionStatusUpdate_common/commonTypes" import {checkSiteOrSystemIsNotifyEnabled} from "../validation/notificationSiteAndSystemFilters" @@ -89,7 +89,8 @@ export async function pushPrescriptionToNotificationSQS( // Build SQS batch entries with FIFO parameters .map((item, idx) => ({ Id: idx.toString(), - MessageBody: JSON.stringify(item), + // Only post the required information to SQS + MessageBody: JSON.stringify(item as NotifyDataItem), // FIFO // We dedupe on both nhs number and ods code MessageDeduplicationId: saltedHash(logger, `${item.PatientNHSNumber}:${item.PharmacyODSCode}`), diff --git a/packages/updatePrescriptionStatus/tests/testSqsClient.test.ts b/packages/updatePrescriptionStatus/tests/testSqsClient.test.ts index e2bc93d18c..c1e1910694 100644 --- a/packages/updatePrescriptionStatus/tests/testSqsClient.test.ts +++ b/packages/updatePrescriptionStatus/tests/testSqsClient.test.ts @@ -203,7 +203,7 @@ describe("Unit tests for checkSiteOrSystemIsNotifyEnabled", () => { it("excludes an item when its ODS code is blocked, even if otherwise enabled", () => { const item = createMockDataItem({ - PharmacyODSCode: "a83008", + PharmacyODSCode: "b3j1z", ApplicationName: "Internal Test System" }) const result = checkSiteOrSystemIsNotifyEnabled([item]) diff --git a/postman/internal.postman_collection.json b/postman/internal.postman_collection.json index bf55418ede..444abc84a5 100644 --- a/postman/internal.postman_collection.json +++ b/postman/internal.postman_collection.json @@ -1,10 +1,10 @@ { "info": { - "_postman_id": "6bf19388-94df-435c-8c93-6a2d4e47b669", + "_postman_id": "4462fdb6-2fa1-495c-8416-6e40d6acdd20", "name": "Internal Collection", "description": "This collection provides endpoints for interacting with the Prescription Status Update API across multiple environments. It is designed for use by developers and testers, encompassing calls to all endpoints.\n\nThe collection is configured through the Digital Onboarding Services using the appropriate environment for the Apigee host, with access enabled for the APIs you intend to use. For Pull Request deployments, make sure to add the APIs specific to the relevant pull request.\n\nThe collection includes the following folders:\n\n- `Custom Stack Deployment` Direct API calls to all endpoints on AWS\n \n- `Pull Request Deployment` API calls to endpoints on both Apigee and AWS\n \n\n### Setup Instructions\n\nTo use this collection, you should define the following variables at a global level:\n\n- `host`\n \n- `status_api_key` Obtain this from the APIM team\n \n\nTo use the requests in the \"Pull Request Deployment\" folder, you should create a variable called `aws_pull_request_id` that represents the number of the pull request.\n\nTo use the requests in the \"Custom Stack Deployment\" folder, you should create a variable called `custom_stack_name` that represents the name of the stack you have defined.\n\n### Authentication Setup\n\nThere is a pre-request script at the top level that automates the authentication process. You must set the following variables for this to work. These should be set at an environment level as they differ between each environment.\n\n- `host`\n \n- `api_key`\n \n- `cpsu_api_key` Used for Custom Prescription Status Update\n \n- `private_key`\n \n- `kid`\n \n\n### Host Configuration\n\nThe `host` should be set to the base Apigee URL, depending on the environment you're working with. Below is a table detailing the URLs, their corresponding environments, and the Digital Onboarding Service registration links.\n\n**Note:** Sandbox environments should be named so that they end with `sandbox`.\n\n| **Apigee URL** | **Environment** | **Digital Onboarding Service** |\n| --- | --- | --- |\n| `internal-dev.api.service.nhs.uk` | Development (dev) | [Digital Onboarding Service PTL](https://dos-internal.ptl.api.platform.nhs.uk/) |\n| `internal-dev-sandbox.api.service.nhs.uk` | Development (dev sandbox) | [Digital Onboarding Service PTL](https://dos-internal.ptl.api.platform.nhs.uk/) |\n| `int.api.service.nhs.uk` | Integration Test (int) | [Digital Onboarding Service PROD](https://onboarding.prod.api.platform.nhs.uk/) |\n| `sandbox.api.service.nhs.uk` | Sandbox (int sandbox) | [Digital Onboarding Service PROD](https://onboarding.prod.api.platform.nhs.uk/) |\n| `internal-qa.api.service.nhs.uk` | Production (qa) | [Digital Onboarding Service PTL](https://dos-internal.ptl.api.platform.nhs.uk/) |\n| `internal-qa.api.service.nhs.uk` | Production (ref) | [Digital Onboarding Service PTL](https://dos-internal.ptl.api.platform.nhs.uk/) |\n\n### API Key and JWT Setup\n\nFollow the instructions at [NHS Developer Documentation](https://digital.nhs.uk/developer/guides-and-documentation/security-and-authorisation/application-restricted-restful-apis-signed-jwt-authentication#step-1-register-your-application-on-the-api-platform) to get an API key and create a public/private key pair, which you need to upload to the JWKS server.\n\n- `api_key` Should be set to the Apigee API key for your application\n \n- `private_key` Should be your private key that you have created for the environment\n \n- `kid` Should be the KID that you used when creating the JWKS", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "24760919" + "_exporter_id": "35340912" }, "item": [ { @@ -52,7 +52,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"resourceType\": \"Bundle\",\n \"type\": \"transaction\",\n \"entry\": [\n {\n \"fullUrl\": \"urn:uuid:{{task_identifier}}\",\n \"resource\": {\n \"resourceType\": \"Task\",\n \"id\": \"{{task_identifier}}\",\n \"basedOn\": [\n {\n \"identifier\": {\n \"system\": \"https://fhir.nhs.uk/Id/prescription-order-number\",\n \"value\": \"16B2E0-A83008-81C13H\"\n }\n }\n ],\n \"status\": \"completed\",\n \"businessStatus\": {\n \"coding\": [\n {\n \"system\": \"https://fhir.nhs.uk/CodeSystem/task-businessStatus-nppt\",\n \"code\": \"dispatched\"\n }\n ]\n },\n \"intent\": \"order\",\n \"focus\": {\n \"identifier\": {\n \"system\": \"https://fhir.nhs.uk/Id/prescription-order-item-number\",\n \"value\": \"6989b7bd-8db6-428c-a593-4022e3044c00\"\n }\n },\n \"for\": {\n \"identifier\": {\n \"system\": \"https://fhir.nhs.uk/Id/nhs-number\",\n \"value\": \"9449304130\"\n }\n },\n \"lastModified\": \"2023-10-11T10:11:12Z\",\n \"owner\": {\n \"identifier\": {\n \"system\": \"https://fhir.nhs.uk/Id/ods-organization-code\",\n \"value\": \"C9Z1O\"\n }\n }\n },\n \"request\": {\n \"method\": \"POST\",\n \"url\": \"Task\"\n }\n }\n ]\n}\n", + "raw": "{\n \"resourceType\": \"Bundle\",\n \"type\": \"transaction\",\n \"entry\": [\n {\n \"fullUrl\": \"urn:uuid:{{task_identifier}}\",\n \"resource\": {\n \"resourceType\": \"Task\",\n \"id\": \"{{task_identifier}}\",\n \"basedOn\": [\n {\n \"identifier\": {\n \"system\": \"https://fhir.nhs.uk/Id/prescription-order-number\",\n \"value\": \"16B2E0-A83008-81C13H\"\n }\n }\n ],\n \"status\": \"completed\",\n \"businessStatus\": {\n \"coding\": [\n {\n \"system\": \"https://fhir.nhs.uk/CodeSystem/task-businessStatus-nppt\",\n \"code\": \"ready to collect\"\n }\n ]\n },\n \"intent\": \"order\",\n \"focus\": {\n \"identifier\": {\n \"system\": \"https://fhir.nhs.uk/Id/prescription-order-item-number\",\n \"value\": \"6989b7be-8db6-428c-a593-4022e3044c00\"\n }\n },\n \"for\": {\n \"identifier\": {\n \"system\": \"https://fhir.nhs.uk/Id/nhs-number\",\n \"value\": \"8308227929\"\n }\n },\n \"lastModified\": \"2023-10-11T10:11:12Z\",\n \"owner\": {\n \"identifier\": {\n \"system\": \"https://fhir.nhs.uk/Id/ods-organization-code\",\n \"value\": \"FA565\"\n }\n }\n },\n \"request\": {\n \"method\": \"POST\",\n \"url\": \"Task\"\n }\n }\n ]\n}\n", "options": { "raw": { "language": "json" @@ -62,8 +62,17 @@ "url": { "raw": "https://psu-pr-{{aws_pull_request_id}}.dev.eps.national.nhs.uk/", "protocol": "https", - "host": ["psu-pr-{{aws_pull_request_id}}", "dev", "eps", "national", "nhs", "uk"], - "path": [""] + "host": [ + "psu-pr-{{aws_pull_request_id}}", + "dev", + "eps", + "national", + "nhs", + "uk" + ], + "path": [ + "" + ] } }, "response": [] @@ -120,8 +129,17 @@ "url": { "raw": "https://psu-pr-{{aws_pull_request_id}}.dev.eps.national.nhs.uk/format-1", "protocol": "https", - "host": ["psu-pr-{{aws_pull_request_id}}", "dev", "eps", "national", "nhs", "uk"], - "path": ["format-1"] + "host": [ + "psu-pr-{{aws_pull_request_id}}", + "dev", + "eps", + "national", + "nhs", + "uk" + ], + "path": [ + "format-1" + ] } }, "response": [] @@ -146,8 +164,17 @@ "url": { "raw": "https://psu-pr-{{aws_pull_request_id}}.dev.eps.national.nhs.uk/_status", "protocol": "https", - "host": ["psu-pr-{{aws_pull_request_id}}", "dev", "eps", "national", "nhs", "uk"], - "path": ["_status"] + "host": [ + "psu-pr-{{aws_pull_request_id}}", + "dev", + "eps", + "national", + "nhs", + "uk" + ], + "path": [ + "_status" + ] } }, "response": [] @@ -197,8 +224,17 @@ "url": { "raw": "https://psu-pr-{{aws_pull_request_id}}.dev.eps.national.nhs.uk/checkprescriptionstatusupdates", "protocol": "https", - "host": ["psu-pr-{{aws_pull_request_id}}", "dev", "eps", "national", "nhs", "uk"], - "path": ["checkprescriptionstatusupdates"], + "host": [ + "psu-pr-{{aws_pull_request_id}}", + "dev", + "eps", + "national", + "nhs", + "uk" + ], + "path": [ + "checkprescriptionstatusupdates" + ], "query": [ { "key": "odscode", @@ -220,6 +256,104 @@ }, "response": [] }, + { + "name": "AWS PULL REQUEST Notify Callback", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const crypto = pm.require('npm:crypto-js@4.2.0')\r", + "\r", + "const appName = pm.environment.get(\"NOTIFY_APP_NAME\");\r", + "const apiKey = pm.environment.get(\"NOTIFY_API_KEY\");\r", + "\r", + "if (!appName || !apiKey) {\r", + " console.error(\"Missing APP_NAME or API_KEY in environment!\");\r", + "}\r", + "\r", + "const secret = `${appName}.${apiKey}`;\r", + "\r", + "let body = \"\";\r", + "const raw = pm.request.body.raw;\r", + "body = pm.variables.replaceIn(raw);\r", + "// need to make sure the body is synced later\r", + "pm.request.body = body\r", + "\r", + "const signature = crypto.HmacSHA256(body, secret).toString(crypto.enc.Hex);\r", + "\r", + "// Expects both the siganture and the api key. The app name is secret?\r", + "pm.request.headers.upsert({\r", + " key: \"x-hmac-sha256-signature\",\r", + " value: signature\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": { + "npm:crypto-js@4.2.0": { + "id": "npm:crypto-js@4.2.0" + } + } + } + } + ], + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{NOTIFY_API_KEY}}", + "type": "string" + }, + { + "key": "key", + "value": "x-api-key", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "x-request-id", + "value": "{{$guid}}", + "type": "text" + }, + { + "key": "x-correlation-id", + "value": "{{$guid}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"data\": [\r\n {\r\n \"type\": \"MessageStatus\",\r\n \"attributes\": {\r\n \"messageId\": \"{{messageID}}\",\r\n \"messageReference\": \"REF-ABC-123\",\r\n \"messageStatus\": \"delivered\",\r\n \"messageStatusDescription\": \"Message has been delivered\",\r\n \"channels\": [\r\n {\r\n \"type\": \"nhsapp\",\r\n \"channelStatus\": \"delivered\"\r\n }\r\n ],\r\n \"timestamp\": \"{{$isoTimestamp}}\",\r\n \"routingPlan\": {\r\n \"id\": \"example-plan\",\r\n \"name\": \"Example Template\",\r\n \"version\": \"v1.0.0\",\r\n \"createdDate\": \"2025-01-01T12:00:00Z\"\r\n }\r\n },\r\n \"links\": {\r\n \"message\": \"https://api.nhs.example.com/messages/{{messageID}}\"\r\n },\r\n \"meta\": {\r\n \"idempotencyKey\": \"idemp-001-abc\"\r\n }\r\n }\r\n ]\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://psu-pr-{{aws_pull_request_id}}.dev.eps.national.nhs.uk/notification-delivery-status-callback", + "protocol": "https", + "host": [ + "psu-pr-{{aws_pull_request_id}}", + "dev", + "eps", + "national", + "nhs", + "uk" + ], + "path": [ + "notification-delivery-status-callback" + ] + } + }, + "response": [] + }, { "name": "AWS PULL REQUEST psu metadata", "request": { @@ -240,8 +374,17 @@ "url": { "raw": "https://psu-pr-{{aws_pull_request_id}}.dev.eps.national.nhs.uk/metadata", "protocol": "https", - "host": ["psu-pr-{{aws_pull_request_id}}", "dev", "eps", "national", "nhs", "uk"], - "path": ["metadata"] + "host": [ + "psu-pr-{{aws_pull_request_id}}", + "dev", + "eps", + "national", + "nhs", + "uk" + ], + "path": [ + "metadata" + ] } }, "response": [] @@ -305,8 +448,13 @@ "url": { "raw": "https://{{host}}/prescription-status-update-pr-{{aws_pull_request_id}}/", "protocol": "https", - "host": ["{{host}}"], - "path": ["prescription-status-update-pr-{{aws_pull_request_id}}", ""] + "host": [ + "{{host}}" + ], + "path": [ + "prescription-status-update-pr-{{aws_pull_request_id}}", + "" + ] } }, "response": [] @@ -371,8 +519,13 @@ "url": { "raw": "https://{{host}}/custom-prescription-status-update-pr-{{aws_pull_request_id}}/format-1", "protocol": "https", - "host": ["{{host}}"], - "path": ["custom-prescription-status-update-pr-{{aws_pull_request_id}}", "format-1"] + "host": [ + "{{host}}" + ], + "path": [ + "custom-prescription-status-update-pr-{{aws_pull_request_id}}", + "format-1" + ] } }, "response": [] @@ -409,8 +562,13 @@ "url": { "raw": "https://{{host}}/prescription-status-update-pr-{{aws_pull_request_id}}/_status", "protocol": "https", - "host": ["{{host}}"], - "path": ["prescription-status-update-pr-{{aws_pull_request_id}}", "_status"] + "host": [ + "{{host}}" + ], + "path": [ + "prescription-status-update-pr-{{aws_pull_request_id}}", + "_status" + ] } }, "response": [] @@ -447,8 +605,13 @@ "url": { "raw": "https://{{host}}/custom-prescription-status-update-pr-{{aws_pull_request_id}}/_status", "protocol": "https", - "host": ["{{host}}"], - "path": ["custom-prescription-status-update-pr-{{aws_pull_request_id}}", "_status"] + "host": [ + "{{host}}" + ], + "path": [ + "custom-prescription-status-update-pr-{{aws_pull_request_id}}", + "_status" + ] } }, "response": [] @@ -495,8 +658,13 @@ "url": { "raw": "https://{{host}}/prescription-status-update-pr-{{aws_pull_request_id}}/checkprescriptionstatusupdates", "protocol": "https", - "host": ["{{host}}"], - "path": ["prescription-status-update-pr-{{aws_pull_request_id}}", "checkprescriptionstatusupdates"], + "host": [ + "{{host}}" + ], + "path": [ + "prescription-status-update-pr-{{aws_pull_request_id}}", + "checkprescriptionstatusupdates" + ], "query": [ { "key": "odscode", @@ -538,8 +706,13 @@ "url": { "raw": "https://{{host}}/prescription-status-update-pr-{{aws_pull_request_id}}/_ping", "protocol": "https", - "host": ["{{host}}"], - "path": ["prescription-status-update-pr-{{aws_pull_request_id}}", "_ping"] + "host": [ + "{{host}}" + ], + "path": [ + "prescription-status-update-pr-{{aws_pull_request_id}}", + "_ping" + ] } }, "response": [] @@ -571,8 +744,13 @@ "url": { "raw": "https://{{host}}/prescription-status-update-pr-{{aws_pull_request_id}}/metadata", "protocol": "https", - "host": ["{{host}}"], - "path": ["prescription-status-update-pr-{{aws_pull_request_id}}", "metadata"] + "host": [ + "{{host}}" + ], + "path": [ + "prescription-status-update-pr-{{aws_pull_request_id}}", + "metadata" + ] } }, "response": [] @@ -625,8 +803,17 @@ "url": { "raw": "https://{{custom_stack_name}}.dev.eps.national.nhs.uk/", "protocol": "https", - "host": ["{{custom_stack_name}}", "dev", "eps", "national", "nhs", "uk"], - "path": [""] + "host": [ + "{{custom_stack_name}}", + "dev", + "eps", + "national", + "nhs", + "uk" + ], + "path": [ + "" + ] } }, "response": [] @@ -674,8 +861,17 @@ "url": { "raw": "https://{{custom_stack_name}}.dev.eps.national.nhs.uk/format-1", "protocol": "https", - "host": ["{{custom_stack_name}}", "dev", "eps", "national", "nhs", "uk"], - "path": ["format-1"] + "host": [ + "{{custom_stack_name}}", + "dev", + "eps", + "national", + "nhs", + "uk" + ], + "path": [ + "format-1" + ] } }, "response": [] @@ -700,8 +896,17 @@ "url": { "raw": "https://{{custom_stack_name}}.dev.eps.national.nhs.uk/_status", "protocol": "https", - "host": ["{{custom_stack_name}}", "dev", "eps", "national", "nhs", "uk"], - "path": ["_status"] + "host": [ + "{{custom_stack_name}}", + "dev", + "eps", + "national", + "nhs", + "uk" + ], + "path": [ + "_status" + ] } }, "response": [] @@ -751,8 +956,17 @@ "url": { "raw": "https://{{custom_stack_name}}.dev.eps.national.nhs.uk/checkprescriptionstatusupdates", "protocol": "https", - "host": ["{{custom_stack_name}}", "dev", "eps", "national", "nhs", "uk"], - "path": ["checkprescriptionstatusupdates"], + "host": [ + "{{custom_stack_name}}", + "dev", + "eps", + "national", + "nhs", + "uk" + ], + "path": [ + "checkprescriptionstatusupdates" + ], "query": [ { "key": "odscode", @@ -794,8 +1008,17 @@ "url": { "raw": "https://{{custom_stack_name}}.dev.eps.national.nhs.uk/metadata", "protocol": "https", - "host": ["{{custom_stack_name}}", "dev", "eps", "national", "nhs", "uk"], - "path": ["metadata"] + "host": [ + "{{custom_stack_name}}", + "dev", + "eps", + "national", + "nhs", + "uk" + ], + "path": [ + "metadata" + ] } }, "response": [] @@ -860,8 +1083,13 @@ "url": { "raw": "https://{{host}}/prescription-status-update/", "protocol": "https", - "host": ["{{host}}"], - "path": ["prescription-status-update", ""] + "host": [ + "{{host}}" + ], + "path": [ + "prescription-status-update", + "" + ] } }, "response": [] @@ -924,8 +1152,13 @@ "url": { "raw": "https://{{host}}/custom-prescription-status-update/format-1", "protocol": "https", - "host": ["{{host}}"], - "path": ["custom-prescription-status-update", "format-1"] + "host": [ + "{{host}}" + ], + "path": [ + "custom-prescription-status-update", + "format-1" + ] } }, "response": [] @@ -972,8 +1205,13 @@ "url": { "raw": "https://{{host}}/prescription-status-update/checkprescriptionstatusupdates", "protocol": "https", - "host": ["{{host}}"], - "path": ["prescription-status-update", "checkprescriptionstatusupdates"], + "host": [ + "{{host}}" + ], + "path": [ + "prescription-status-update", + "checkprescriptionstatusupdates" + ], "query": [ { "key": "odscode", @@ -1015,8 +1253,13 @@ "url": { "raw": "https://{{host}}/prescription-status-update/_ping", "protocol": "https", - "host": ["{{host}}"], - "path": ["prescription-status-update", "_ping"] + "host": [ + "{{host}}" + ], + "path": [ + "prescription-status-update", + "_ping" + ] } }, "response": [] @@ -1048,8 +1291,13 @@ "url": { "raw": "https://{{host}}/prescription-status-update/metadata", "protocol": "https", - "host": ["{{host}}"], - "path": ["prescription-status-update", "metadata"] + "host": [ + "{{host}}" + ], + "path": [ + "prescription-status-update", + "metadata" + ] } }, "response": [] @@ -1086,8 +1334,13 @@ "url": { "raw": "https://{{host}}/prescription-status-update/_status", "protocol": "https", - "host": ["{{host}}"], - "path": ["prescription-status-update", "_status"] + "host": [ + "{{host}}" + ], + "path": [ + "prescription-status-update", + "_status" + ] } }, "response": [] @@ -1124,8 +1377,13 @@ "url": { "raw": "https://{{host}}/custom-prescription-status-update/_status", "protocol": "https", - "host": ["{{host}}"], - "path": ["custom-prescription-status-update", "_status"] + "host": [ + "{{host}}" + ], + "path": [ + "custom-prescription-status-update", + "_status" + ] } }, "response": [] @@ -1138,7 +1396,6 @@ "type": "text/javascript", "packages": {}, "exec": [ - "\r", "const uuid = require('uuid')\r", "\r", "const privateKey = pm.environment.get('private_key') || ''\r", @@ -1274,7 +1531,9 @@ "script": { "type": "text/javascript", "packages": {}, - "exec": [""] + "exec": [ + "" + ] } } ] diff --git a/sonar-project.properties b/sonar-project.properties index 83893d6c71..bc18d034ba 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -20,4 +20,5 @@ sonar.javascript.lcov.reportPaths=\ packages/cpsuLambda/coverage/lcov.info, \ packages/checkPrescriptionStatusUpdates/coverage/lcov.info, \ packages/nhsNotifyLambda/coverage/lcov.info, \ + packages/nhsNotifyUpdateCallback/coverage/lcov.info, \ packages/common/middyErrorHandler/coverage/lcov.info diff --git a/tsconfig.build.json b/tsconfig.build.json index c3e3ce9ba5..e391642255 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -14,6 +14,7 @@ {"path": "packages/capabilityStatement"}, {"path": "packages/cpsuLambda"}, {"path": "packages/checkPrescriptionStatusUpdates"}, - {"path": "packages/nhsNotifyLambda"} + {"path": "packages/nhsNotifyLambda"}, + {"path": "packages/nhsNotifyUpdateCallback"} ] }