diff --git a/.github/workflows/base-lambdas-reusable-deploy-all.yml b/.github/workflows/base-lambdas-reusable-deploy-all.yml index 89722bf25..495c36fb4 100644 --- a/.github/workflows/base-lambdas-reusable-deploy-all.yml +++ b/.github/workflows/base-lambdas-reusable-deploy-all.yml @@ -643,6 +643,20 @@ jobs: secrets: AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }} + deploy_fhir_document_reference_delete_lambda: + name: Deploy Delete Document References FHIR Lambda + uses: ./.github/workflows/base-lambdas-reusable-deploy.yml + with: + environment: ${{ inputs.environment}} + python_version: ${{ inputs.python_version }} + build_branch: ${{ inputs.build_branch}} + sandbox: ${{ inputs.sandbox }} + lambda_handler_name: delete_fhir_document_reference_handler + lambda_aws_name: DeleteDocumentReferencesFHIR + lambda_layer_names: "core_lambda_layer" + secrets: + AWS_ASSUME_ROLE: ${{ secrets.AWS_ASSUME_ROLE }} + deploy_alerting_lambda: name: Deploy Alerting lambda uses: ./.github/workflows/base-lambdas-reusable-deploy.yml diff --git a/Makefile b/Makefile index 6129159b2..25017ef8d 100644 --- a/Makefile +++ b/Makefile @@ -116,6 +116,7 @@ build-and-deploy-sandbox: ## Build a sandbox and deploy code. If no SANDBOX_NAME $(if $(SANDBOX_NAME),--sandbox_name=$(SANDBOX_NAME)) \ $(if $(BUILD_INFRA),--build_infra=$(BUILD_INFRA)) \ $(if $(FULL_DEPLOY),--full_deploy=$(FULL_DEPLOY)) \ + $(if $(SKIP_MAIN),--skip_main=$(SKIP_MAIN)) \ $(if $(NDRI_DIR_LOC_OVERRIDE),--ndri_dir_loc_override=$(NDRI_DIR_LOC_OVERRIDE)) download-api-certs: ## Downloads mTLS certificates (use with dev envs only). Usage: make download-api-certs WORKSPACE= diff --git a/lambdas/enums/logging_app_interaction.py b/lambdas/enums/logging_app_interaction.py index 8bbc822ed..4383622cc 100644 --- a/lambdas/enums/logging_app_interaction.py +++ b/lambdas/enums/logging_app_interaction.py @@ -9,6 +9,7 @@ class LoggingAppInteraction(Enum): VIEW_LG_RECORD = "View LG record" DOWNLOAD_RECORD = "Download a record" DELETE_RECORD = "Delete a record" + FHIR_DELETE_RECORD = "Delete a FHIR record" UPLOAD_RECORD = "Upload a record" UPDATE_RECORD = "Update a record" STITCH_RECORD = "Stitch a record" diff --git a/lambdas/handlers/delete_fhir_document_reference_handler.py b/lambdas/handlers/delete_fhir_document_reference_handler.py new file mode 100644 index 000000000..e765a8d1f --- /dev/null +++ b/lambdas/handlers/delete_fhir_document_reference_handler.py @@ -0,0 +1,70 @@ +from enums.fhir.fhir_issue_type import FhirIssueCoding +from enums.lambda_error import LambdaError +from enums.logging_app_interaction import LoggingAppInteraction +from services.delete_fhir_document_reference_service import ( + DeleteFhirDocumentReferenceService, +) +from utils.audit_logging_setup import LoggingService +from utils.decorators.ensure_env_var import ensure_environment_variables +from utils.decorators.handle_lambda_exceptions import handle_lambda_exceptions_fhir +from utils.decorators.set_audit_arg import set_request_context_for_logging +from utils.error_response import ErrorResponse +from utils.exceptions import FhirDocumentReferenceException +from utils.lambda_response import ApiGatewayResponse +from utils.request_context import request_context + +logger = LoggingService(__name__) + + +@handle_lambda_exceptions_fhir +@set_request_context_for_logging +@ensure_environment_variables( + names=[ + "DOCUMENT_STORE_DYNAMODB_NAME", + "LLOYD_GEORGE_DYNAMODB_NAME", + "UNSTITCHED_LLOYD_GEORGE_DYNAMODB_NAME", + ] +) +def lambda_handler(event, context): + try: + request_context.app_interaction = LoggingAppInteraction.FHIR_DELETE_RECORD.value + logger.info("Processing FHIR document reference DELETE request") + fhir_doc_ref_service = DeleteFhirDocumentReferenceService() + + fhir_response = fhir_doc_ref_service.process_fhir_document_reference(event) + + if fhir_response: + logger.info( + "FHIR Documents were deleted successfully", + {"Result": "Successful deletion"}, + ) + return ApiGatewayResponse( + status_code=204, methods="DELETE" + ).create_api_gateway_response() + else: + logger.error( + LambdaError.DocDelNull.to_str(), + { + "Result": "No matching documents available during FHIR DELETE request" + }, + ) + + return ApiGatewayResponse( + status_code=404, + body=ErrorResponse( + "404", + "No Documents found for deletion.", + getattr(request_context, "request_id", None), + ).create_error_fhir_response(FhirIssueCoding.NOT_FOUND), + methods="DELETE", + ).create_api_gateway_response() + + except FhirDocumentReferenceException as exception: + logger.error(f"Error processing FHIR document reference: {str(exception)}") + return ApiGatewayResponse( + status_code=exception.status_code, + body=exception.error.create_error_response().create_error_fhir_response( + exception.error.value.get("fhir_coding") + ), + methods="DELETE", + ).create_api_gateway_response() diff --git a/lambdas/services/delete_fhir_document_reference_service.py b/lambdas/services/delete_fhir_document_reference_service.py new file mode 100644 index 000000000..a1150fb95 --- /dev/null +++ b/lambdas/services/delete_fhir_document_reference_service.py @@ -0,0 +1,217 @@ +import uuid +from datetime import datetime, timezone +from typing import Dict, Optional + +from botocore.exceptions import ClientError +from enums.document_retention import DocumentRetentionDays +from enums.lambda_error import LambdaError +from enums.metadata_field_names import DocumentReferenceMetadataFields +from enums.mtls import MtlsCommonNames +from enums.snomed_codes import SnomedCode, SnomedCodes +from enums.supported_document_types import SupportedDocumentTypes +from models.document_reference import DocumentReference +from pydantic import ValidationError +from services.document_deletion_service import DocumentDeletionService +from services.document_service import DocumentService +from services.fhir_document_reference_service_base import ( + FhirDocumentReferenceServiceBase, +) +from utils.audit_logging_setup import LoggingService +from utils.common_query_filters import NotDeleted +from utils.exceptions import DynamoServiceException, InvalidNhsNumberException +from utils.lambda_exceptions import ( + DocumentDeletionServiceException, + DocumentRefException, +) +from utils.lambda_header_utils import validate_common_name_in_mtls +from utils.request_context import request_context +from utils.utilities import validate_nhs_number + +PARAM_SUBJECT_IDENTIFIER = "subject:identifier" +DOCUMENT_REFERENCE_IDENTIFIER = "_id" + +logger = LoggingService(__name__) + + +class DeleteFhirDocumentReferenceService(FhirDocumentReferenceServiceBase): + def __init__(self): + super().__init__() + + def process_fhir_document_reference( + self, event: dict = {} + ) -> list[DocumentReference]: + """ + Process a FHIR Document Reference DELETE request + + Returns: + FHIR Document Reference response + """ + try: + common_name = validate_common_name_in_mtls(event.get("requestContext", {})) + deletion_identifiers = self.extract_parameters(event=event) + if any(v is None for v in deletion_identifiers): + logger.error("FHIR document validation error: NhsNumber/id") + raise DocumentRefException( + 400, LambdaError.DocumentReferenceMissingParameters + ) + + if len(deletion_identifiers) < 2: + return [] + + if not self.is_uuid(deletion_identifiers[0]): + logger.error("FHIR document validation error: Id") + raise DocumentRefException( + 400, LambdaError.DocumentReferenceMissingParameters + ) + + doc_type = self._determine_document_type_based_on_common_name(common_name) + + if not validate_nhs_number(deletion_identifiers[1]): + logger.error("FHIR document validation error: NhsNumber") + raise DocumentRefException( + 400, LambdaError.DocumentReferenceMissingParameters + ) + + request_context.patient_nhs_no = deletion_identifiers[1] + if doc_type.code != SnomedCodes.PATIENT_DATA.value.code: + deletion_service = DocumentDeletionService() + + document_types = [SupportedDocumentTypes.LG] + files_deleted = deletion_service.handle_reference_delete( + deletion_identifiers[1], + document_types, + document_id=deletion_identifiers[0], + fhir=True, + ) + else: + files_deleted = ( + self.delete_fhir_document_reference_by_nhs_id_and_doc_id( + nhs_number=deletion_identifiers[1], + doc_id=deletion_identifiers[0], + doc_type=doc_type, + ) + ) + return files_deleted + + except (ValidationError, InvalidNhsNumberException) as e: + logger.error(f"FHIR document validation error: {str(e)}") + raise DocumentRefException(400, LambdaError.DocRefNoParse) + except ClientError as e: + logger.error(f"AWS client error: {str(e)}") + raise DocumentRefException(500, LambdaError.InternalServerError) + + def delete_fhir_document_reference_by_nhs_id_and_doc_id( + self, nhs_number: str, doc_id: str, doc_type: SnomedCode + ) -> DocumentReference | None: + dynamo_table = self._get_dynamo_table_for_doc_type(doc_type) + document_service = DocumentService() + document = document_service.get_item_agnostic( + partion_key={DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs_number}, + sort_key={DocumentReferenceMetadataFields.ID.value: doc_id}, + table_name=dynamo_table, + ) + if not document: + return None + try: + document_service.delete_document_reference( + table_name=dynamo_table, + document_reference=document, + document_ttl_days=DocumentRetentionDays.SOFT_DELETE, + key_pair={ + DocumentReferenceMetadataFields.ID.value: doc_id, + DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs_number, + }, + ) + logger.info( + f"Deleted document of type {doc_type.display_name}", + {"Result": "Successful deletion"}, + ) + return document + except (ClientError, DynamoServiceException) as e: + logger.error( + f"{LambdaError.DocDelClient.to_str()}: {str(e)}", + {"Results": "Failed to delete documents"}, + ) + raise DocumentDeletionServiceException(500, LambdaError.DocDelClient) + + def delete_fhir_document_references_by_nhs_id( + self, nhs_number: str, doc_type: SnomedCode + ) -> list[DocumentReference] | None: + dynamo_table = self._get_dynamo_table_for_doc_type(doc_type) + document_service = DocumentService() + documents = document_service.fetch_documents_from_table( + search_condition=nhs_number, + search_key="NhsNumber", + table_name=dynamo_table, + query_filter=NotDeleted, + ) + if not documents: + return None + try: + document_service.delete_document_references( + table_name=dynamo_table, + document_references=documents, + document_ttl_days=DocumentRetentionDays.SOFT_DELETE, + ) + logger.info( + f"Deleted document of type {doc_type.display_name}", + {"Result": "Successful deletion"}, + ) + return documents + except (ClientError, DynamoServiceException) as e: + logger.error( + f"{LambdaError.DocDelClient.to_str()}: {str(e)}", + {"Results": "Failed to delete documents"}, + ) + raise DocumentDeletionServiceException(500, LambdaError.DocDelClient) + + def _determine_document_type_based_on_common_name( + self, common_name: MtlsCommonNames | None + ) -> SnomedCode: + if not common_name: + """Determine the document type based on common_name""" + return SnomedCodes.LLOYD_GEORGE.value + if common_name not in MtlsCommonNames: + logger.error(f"mTLS common name {common_name} - is not supported") + raise DocumentRefException(400, LambdaError.DocRefInvalidType) + + return SnomedCodes.PATIENT_DATA.value + + def is_uuid(self, value: str) -> bool: + try: + uuid.UUID(value) + return True + except (ValueError, TypeError): + return False + + def extract_parameters(self, event) -> list[str]: + nhs_id, document_reference_id = self.extract_document_query_parameters( + event.get("queryStringParameters") or {} + ) + + if not nhs_id or not document_reference_id: + logger.error("FHIR document validation error: Missing query parameters.") + raise DocumentRefException(400, LambdaError.DocRefNoParse) + + return [document_reference_id, nhs_id] + + def extract_document_path_parameters(self, event): + """Extract DocumentReference ID from path parameters""" + doc_ref_id = (event.get("pathParameters") or {}).get("id") + return doc_ref_id + + def extract_document_query_parameters( + self, query_string: Dict[str, str] + ) -> tuple[Optional[str], Optional[str]]: + nhs_number = None + document_reference_id = None + + for key, value in query_string.items(): + if key == PARAM_SUBJECT_IDENTIFIER: + nhs_number = value.split("|")[-1] + elif key == DOCUMENT_REFERENCE_IDENTIFIER: + document_reference_id = value + else: + logger.warning(f"Unknown query parameter: {key}") + + return nhs_number, document_reference_id diff --git a/lambdas/services/document_deletion_service.py b/lambdas/services/document_deletion_service.py index cc4c6c100..5de5d209b 100644 --- a/lambdas/services/document_deletion_service.py +++ b/lambdas/services/document_deletion_service.py @@ -31,16 +31,21 @@ def __init__(self): self.document_service = DocumentService() self.stitch_service = LloydGeorgeStitchJobService() self.sqs_service = SQSService() + self.fhir = False def handle_reference_delete( self, nhs_number: str, doc_types: list[SupportedDocumentTypes], document_id: str | None = None, + fhir: bool = False, ) -> list[DocumentReference]: if document_id: - self.delete_document_by_id(nhs_number, document_id) - return [document_id] + self.fhir = fhir + result = self.delete_document_by_id(nhs_number, document_id) + if result != []: + return [document_id] + return result else: return self.delete_documents_by_types(nhs_number, doc_types) @@ -54,6 +59,8 @@ def delete_document_by_id(self, nhs_number: str, document_id: str): ) if len(doc_ref_list) == 0: + if self.fhir: + return [] raise DocumentDeletionServiceException(404, LambdaError.DocDelNull) document_ref: DocumentReference = doc_ref_list[0] diff --git a/lambdas/services/document_service.py b/lambdas/services/document_service.py index 7e5fabd60..f64ea4dae 100644 --- a/lambdas/services/document_service.py +++ b/lambdas/services/document_service.py @@ -106,6 +106,37 @@ def fetch_documents_from_table( continue return documents + def _get_item(self, table_name, key, model_class): + try: + response = self.dynamo_service.get_item(table_name=table_name, key=key) + if "Item" not in response: + logger.info("No document found") + return None + + document = model_class.model_validate(response["Item"]) + return document + + except ValidationError as e: + logger.error(f"Validation error on document: {response.get('Item')}") + logger.error(f"{e}") + return None + + def get_item_agnostic( + self, + partion_key: dict, + sort_key: dict | None = None, + table_name: str | None = None, + model_class: type[BaseModel] | None = None, + ) -> Optional[BaseModel]: + table_name = table_name or self.table_name + model_class = model_class or self.model_class + + return self._get_item( + table_name=table_name, + key=(partion_key or {}) | (sort_key or {}), + model_class=model_class, + ) + def get_item( self, document_id: str, @@ -129,22 +160,9 @@ def get_item( document_key = {"ID": document_id} if sort_key: document_key.update(sort_key) - try: - response = self.dynamo_service.get_item( - table_name=table_to_use, key=document_key - ) - - if "Item" not in response: - logger.info(f"No document found for document_id: {document_id}") - return None - - document = model_to_use.model_validate(response["Item"]) - return document - - except ValidationError as e: - logger.error(f"Validation error on document: {response.get('Item')}") - logger.error(f"{e}") - return None + return self._get_item( + table_name=table_to_use, key=document_key, model_class=model_to_use + ) def get_nhs_numbers_based_on_ods_code( self, ods_code: str, table_name: str | None = None @@ -162,33 +180,54 @@ def get_nhs_numbers_based_on_ods_code( nhs_numbers = list({document.nhs_number for document in documents}) return nhs_numbers - def delete_document_references( + def delete_document_reference( self, table_name: str, - document_references: list[DocumentReference], + document_reference: DocumentReference, document_ttl_days: int, + key_pair: dict, + deletion_date: datetime | None = None, ): - deletion_date = datetime.now(timezone.utc) + if not deletion_date: + deletion_date = datetime.now(timezone.utc) ttl_seconds = document_ttl_days * 24 * 60 * 60 document_reference_ttl = int(deletion_date.timestamp() + ttl_seconds) + logger.info(f"Deleting document reference in table: {table_name}") + + document_reference.doc_status = "deprecated" + document_reference.deleted = deletion_date.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + document_reference.ttl = document_reference_ttl + + update_fields = document_reference.model_dump( + by_alias=True, + exclude_none=True, + include={"doc_status", "deleted", "ttl"}, + ) + self.dynamo_service.update_item( + table_name=table_name, + key_pair=key_pair, + updated_fields=update_fields, + ) + + def delete_document_references( + self, + table_name: str, + document_references: list[DocumentReference], + document_ttl_days: int, + ): + deletion_date = datetime.now(timezone.utc) + logger.info(f"Deleting items in table: {table_name}") for reference in document_references: - reference.doc_status = "deprecated" - reference.deleted = deletion_date.strftime("%Y-%m-%dT%H:%M:%S.%fZ") - reference.ttl = document_reference_ttl - - update_fields = reference.model_dump( - by_alias=True, - exclude_none=True, - include={"doc_status", "deleted", "ttl"}, - ) - self.dynamo_service.update_item( + self.delete_document_reference( table_name=table_name, + document_reference=reference, + document_ttl_days=document_ttl_days, key_pair={DocumentReferenceMetadataFields.ID.value: reference.id}, - updated_fields=update_fields, + deletion_date=deletion_date, ) def delete_document_object(self, bucket: str, key: str): diff --git a/lambdas/tests/e2e/api/fhir/conftest.py b/lambdas/tests/e2e/api/fhir/conftest.py index b579d48ba..989ae5aa3 100644 --- a/lambdas/tests/e2e/api/fhir/conftest.py +++ b/lambdas/tests/e2e/api/fhir/conftest.py @@ -113,6 +113,22 @@ def get_pdm_document_reference( return response +def delete_document_reference(endpoint, client_cert_path=None, client_key_path=None): + """Helper to perform a DELETE by NHS number.""" + url = f"https://{MTLS_ENDPOINT}/DocumentReference{endpoint}" + headers = { + "X-Correlation-Id": "1234", + } + + # Use provided certs if available, else defaults + if client_cert_path and client_key_path: + session = create_mtls_session(client_cert_path, client_key_path) + else: + session = create_mtls_session() + + return session.delete(url=url, headers=headers) + + def create_and_store_pdm_record( test_data, nhs_number: str = "9912003071", diff --git a/lambdas/tests/e2e/api/fhir/test_delete_document_reference_fhir_api_failure.py b/lambdas/tests/e2e/api/fhir/test_delete_document_reference_fhir_api_failure.py new file mode 100644 index 000000000..4517d7cdd --- /dev/null +++ b/lambdas/tests/e2e/api/fhir/test_delete_document_reference_fhir_api_failure.py @@ -0,0 +1,71 @@ +import uuid +from tests.e2e.api.fhir.conftest import ( + delete_document_reference, +) +from tests.e2e.helpers.data_helper import PdmDataHelper + +pdm_data_helper = PdmDataHelper() + + +def test_no_documents_found(test_data): + response = delete_document_reference( + f"?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|9912003071&_id={uuid.uuid4()}" + ) + assert response.status_code == 404 + response_json = response.json() + assert response_json["resourceType"] == "OperationOutcome" + assert response_json["issue"][0]["details"]["coding"][0]["code"] == "not-found" + + +def test_malformatted_nhs_id(test_data): + response = delete_document_reference( + f"?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|991200&_id={uuid.uuid4()}" + ) + assert response.status_code == 400 + response_json = response.json() + assert response_json["resourceType"] == "OperationOutcome" + assert ( + response_json["issue"][0]["details"]["coding"][0]["code"] == "VALIDATION_ERROR" + ) + + +def test_malformatted_document_id(test_data): + response = delete_document_reference( + "?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|9912003071&_id=1234" + ) + assert response.status_code == 400 + response_json = response.json() + assert response_json["resourceType"] == "OperationOutcome" + assert response_json["issue"][0]["details"]["coding"][0]["code"] == "MISSING_VALUE" + + +def test_no_query_params(test_data): + response = delete_document_reference("") + assert response.status_code == 400 + response_json = response.json() + assert response_json["resourceType"] == "OperationOutcome" + assert ( + response_json["issue"][0]["details"]["coding"][0]["code"] == "VALIDATION_ERROR" + ) + + +def test_incorrect_query_params(test_data): + response = delete_document_reference( + "?foo=https://fhir.nhs.uk/Id/nhs-number|9912003071" + ) + assert response.status_code == 400 + response_json = response.json() + assert response_json["resourceType"] == "OperationOutcome" + assert ( + response_json["issue"][0]["details"]["coding"][0]["code"] == "VALIDATION_ERROR" + ) + + +def test_correct_query_params_with_incorrect_params(test_data): + response = delete_document_reference( + f"?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|9912003071&_id={uuid.uuid4()}&foo=1234" + ) + assert response.status_code == 404 + response_json = response.json() + assert response_json["resourceType"] == "OperationOutcome" + assert response_json["issue"][0]["details"]["coding"][0]["code"] == "not-found" diff --git a/lambdas/tests/e2e/api/fhir/test_delete_document_reference_fhir_api_success.py b/lambdas/tests/e2e/api/fhir/test_delete_document_reference_fhir_api_success.py new file mode 100644 index 000000000..75d7e38e3 --- /dev/null +++ b/lambdas/tests/e2e/api/fhir/test_delete_document_reference_fhir_api_success.py @@ -0,0 +1,49 @@ +from tests.e2e.api.fhir.conftest import ( + create_and_store_pdm_record, + get_pdm_document_reference, + delete_document_reference, +) +from tests.e2e.helpers.data_helper import PdmDataHelper + +pdm_data_helper = PdmDataHelper() + + +def test_delete_record_by_patient_details_and_doc_id(test_data): + created_record = create_and_store_pdm_record(test_data) + expected_record_id = created_record["id"] + + get_response_1 = get_pdm_document_reference(expected_record_id) + assert get_response_1.status_code == 200 + + response = delete_document_reference( + f"?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|9912003071&_id={expected_record_id}" + ) + assert response.status_code == 204 + + get_response = get_pdm_document_reference(expected_record_id) + assert get_response.status_code == 404 + + +def test_delete_only_one_record_by_patient_details_and_doc_id(test_data): + created_record_1 = create_and_store_pdm_record(test_data) + expected_record_id_1 = created_record_1["id"] + + created_record_2 = create_and_store_pdm_record(test_data) + expected_record_id_2 = created_record_2["id"] + + get_response_1 = get_pdm_document_reference(expected_record_id_1) + assert get_response_1.status_code == 200 + + get_response_2 = get_pdm_document_reference(expected_record_id_2) + assert get_response_2.status_code == 200 + + response = delete_document_reference( + f"?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|9912003071&_id={expected_record_id_1}" + ) + assert response.status_code == 204 + + get_response_1_deleted = get_pdm_document_reference(expected_record_id_1) + assert get_response_1_deleted.status_code == 404 + + get_response_2_deleted = get_pdm_document_reference(expected_record_id_2) + assert get_response_2_deleted.status_code == 200 diff --git a/lambdas/tests/e2e/api/test_delete_document_reference_fhir_api_failure.py b/lambdas/tests/e2e/api/test_delete_document_reference_fhir_api_failure.py new file mode 100644 index 000000000..4498ccc97 --- /dev/null +++ b/lambdas/tests/e2e/api/test_delete_document_reference_fhir_api_failure.py @@ -0,0 +1,82 @@ +import requests +import uuid +from tests.e2e.conftest import API_ENDPOINT, API_KEY +from tests.e2e.helpers.data_helper import PdmDataHelper + +pdm_data_helper = PdmDataHelper() + + +def delete_document_reference(end_point): + """Helper to perform a DELETE by NHS number with optional mTLS certs.""" + url = f"https://{API_ENDPOINT}/FhirDocumentReference{end_point}" + headers = { + "Authorization": "Bearer 123", + "X-Api-Key": API_KEY, + "X-Correlation-Id": "1234", + } + + return requests.request("DELETE", url, headers=headers) + + +def test_no_documents_found(test_data): + response = delete_document_reference( + f"?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|9912003071&_id={uuid.uuid4()}" + ) + assert response.status_code == 404 + response_json = response.json() + assert response_json["resourceType"] == "OperationOutcome" + assert response_json["issue"][0]["details"]["coding"][0]["code"] == "not-found" + + +def test_malformatted_nhs_id(test_data): + response = delete_document_reference( + f"?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|991200&_id={uuid.uuid4()}" + ) + assert response.status_code == 400 + response_json = response.json() + assert response_json["resourceType"] == "OperationOutcome" + assert ( + response_json["issue"][0]["details"]["coding"][0]["code"] == "VALIDATION_ERROR" + ) + + +def test_malformatted_document_id(test_data): + response = delete_document_reference( + "?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|9912003071&_id=1234" + ) + assert response.status_code == 400 + response_json = response.json() + assert response_json["resourceType"] == "OperationOutcome" + assert response_json["issue"][0]["details"]["coding"][0]["code"] == "MISSING_VALUE" + + +def test_no_query_params(test_data): + response = delete_document_reference("") + assert response.status_code == 400 + response_json = response.json() + assert response_json["resourceType"] == "OperationOutcome" + assert ( + response_json["issue"][0]["details"]["coding"][0]["code"] == "VALIDATION_ERROR" + ) + + +def test_incorrect_query_params(test_data): + response = delete_document_reference( + "?foo=https://fhir.nhs.uk/Id/nhs-number|9912003071" + ) + assert response.status_code == 400 + response_json = response.json() + assert response_json["resourceType"] == "OperationOutcome" + assert ( + response_json["issue"][0]["details"]["coding"][0]["code"] == "VALIDATION_ERROR" + ) + + +def test_correct_query_params_with_incorrect_params(test_data): + response = delete_document_reference( + f"?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|9912003071&_id={uuid.uuid4()}&foo=1234" + ) + assert response.status_code == 404 + response_json = response.json() + assert response_json["resourceType"] == "OperationOutcome" + assert response_json["issue"][0]["details"]["coding"][0]["code"] == "not-found" diff --git a/lambdas/tests/e2e/api/test_delete_document_reference_fhir_api_success.py b/lambdas/tests/e2e/api/test_delete_document_reference_fhir_api_success.py new file mode 100644 index 000000000..2b44d8107 --- /dev/null +++ b/lambdas/tests/e2e/api/test_delete_document_reference_fhir_api_success.py @@ -0,0 +1,71 @@ +import io +import uuid + +import requests +from tests.e2e.conftest import API_ENDPOINT, API_KEY, LLOYD_GEORGE_SNOMED +from tests.e2e.helpers.data_helper import LloydGeorgeDataHelper + +data_helper = LloydGeorgeDataHelper() + + +def _search_document_status(body, expected): + assert body["resourceType"] == "Bundle" + for entry in body["entry"]: + assert entry["resource"]["docStatus"] == expected + + +def test_delete_record_by_patient_details(test_data): + lloyd_george_record = {} + test_data.append(lloyd_george_record) + + lloyd_george_record["id"] = str(uuid.uuid4()) + lloyd_george_record["nhs_number"] = "9449305943" + lloyd_george_record["data"] = io.BytesIO(b"Sample PDF Content") + + data_helper.create_metadata(lloyd_george_record) + data_helper.create_resource(lloyd_george_record) + + url = f"https://{API_ENDPOINT}/FhirDocumentReference?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|{lloyd_george_record['nhs_number']}&_id={lloyd_george_record['id']}" + headers = { + "Authorization": "Bearer 123", + "X-Api-Key": API_KEY, + "X-Correlation-Id": "1234", + } + get_response = requests.request("GET", url, headers=headers) + assert get_response.status_code == 200 + _search_document_status(get_response.json(), "final") + + delete_response = requests.request("DELETE", url, headers=headers) + assert delete_response.status_code == 204 + + get_response_final = requests.request("GET", url, headers=headers) + assert get_response_final.status_code == 200 + _search_document_status(get_response_final.json(), "deprecated") + + +def test_delete_record_by_patient_details_and_get_by_id(test_data): + lloyd_george_record = {} + test_data.append(lloyd_george_record) + + lloyd_george_record["id"] = str(uuid.uuid4()) + lloyd_george_record["nhs_number"] = "9449305943" + lloyd_george_record["data"] = io.BytesIO(b"Sample PDF Content") + + data_helper.create_metadata(lloyd_george_record) + data_helper.create_resource(lloyd_george_record) + + url = f"https://{API_ENDPOINT}/FhirDocumentReference/{LLOYD_GEORGE_SNOMED}~{lloyd_george_record['id']}" + headers = { + "Authorization": "Bearer 123", + "X-Api-Key": API_KEY, + "X-Correlation-Id": "1234", + } + get_response = requests.request("GET", url, headers=headers) + assert get_response.status_code == 200 + + delete_url = f"https://{API_ENDPOINT}/FhirDocumentReference?subject:identifier=https://fhir.nhs.uk/Id/nhs-number|{lloyd_george_record['nhs_number']}&_id={lloyd_george_record['id']}" + delete_response = requests.request("DELETE", delete_url, headers=headers) + assert delete_response.status_code == 204 + + get_response_final = requests.request("GET", url, headers=headers) + assert get_response_final.status_code == 404 diff --git a/lambdas/tests/unit/handlers/test_login_redirect_handler.py b/lambdas/tests/unit/handlers/test_login_redirect_handler.py index 6f3cda998..08b0366f9 100644 --- a/lambdas/tests/unit/handlers/test_login_redirect_handler.py +++ b/lambdas/tests/unit/handlers/test_login_redirect_handler.py @@ -43,7 +43,7 @@ def test_login_redirect_lambda_handler_valid( response = lambda_handler(event, context) mock_login_service_object.prepare_redirect_response.assert_called_once() assert response["statusCode"] == 303 - assert response["body"] == "" + assert "body" not in response def test_login_redirect_lambda_handler_exception( diff --git a/lambdas/tests/unit/handlers/test_pdm_delete_fhir_document_reference_by_nhs_id_handler.py b/lambdas/tests/unit/handlers/test_pdm_delete_fhir_document_reference_by_nhs_id_handler.py new file mode 100644 index 000000000..f0dc32eff --- /dev/null +++ b/lambdas/tests/unit/handlers/test_pdm_delete_fhir_document_reference_by_nhs_id_handler.py @@ -0,0 +1,111 @@ +import json + +import pytest +from enums.lambda_error import LambdaError +from enums.snomed_codes import SnomedCodes +from handlers.delete_fhir_document_reference_handler import ( + lambda_handler, +) +from models.document_reference import DocumentReference +from tests.unit.conftest import TEST_NHS_NUMBER +from tests.unit.helpers.data.dynamo.dynamo_responses import MOCK_SEARCH_RESPONSE +from utils.lambda_exceptions import ( + DocumentRefException, +) + +SNOMED_CODE = SnomedCodes.PATIENT_DATA.value.code + +MOCK_MTLS_VALID_EVENT = { + "httpMethod": "DELETE", + "headers": {}, + "queryStringParameters": { + "subject:identifier": f"https://fhir.nhs.uk/Id/nhs-number|{TEST_NHS_NUMBER}" + }, + "body": None, + "requestContext": { + "accountId": "123456789012", + "apiId": "abc123", + "domainName": "api.example.com", + "identity": { + "sourceIp": "1.2.3.4", + "userAgent": "curl/7.64.1", + "clientCert": { + "clientCertPem": "-----BEGIN CERTIFICATE-----...", + "subjectDN": "CN=ndrclient.main.int.pdm.national.nhs.uk,O=NHS,C=UK", + "issuerDN": "CN=NHS Root CA,O=NHS,C=UK", + "serialNumber": "12:34:56", + "validity": { + "notBefore": "May 10 00:00:00 2024 GMT", + "notAfter": "May 10 00:00:00 2025 GMT", + }, + }, + }, + }, +} + +MOCK_DOCUMENT_REFERENCE = DocumentReference.model_validate( + MOCK_SEARCH_RESPONSE["Items"][0] +) + + +@pytest.fixture +def mock_service(mocker): + mock_service = mocker.patch( + "handlers.delete_fhir_document_reference_handler.DeleteFhirDocumentReferenceService" + ) + mock_service_instance = mock_service.return_value + return mock_service_instance + + +@pytest.mark.parametrize( + "returned_service_value, expected_status", + [([], 404), (MOCK_DOCUMENT_REFERENCE, 204)], +) +def test_lambda_handler_document_reference_not_found( + set_env, mock_service, context, returned_service_value, expected_status +): + mock_response = returned_service_value + + mock_service.process_fhir_document_reference.return_value = mock_response + response = lambda_handler(MOCK_MTLS_VALID_EVENT, context) + assert response["statusCode"] == expected_status + assert response["headers"]["Access-Control-Allow-Methods"] == "DELETE" + if expected_status != 204: + assert "body" in response + body = json.loads(response["body"]) + assert body["resourceType"] == "OperationOutcome" + assert body["issue"][0]["details"]["coding"][0]["code"] == "not-found" + assert body["issue"][0]["diagnostics"] == "No Documents found for deletion." + else: + assert "body" not in response + + +def test_lambda_handler_validation_error(set_env, mock_service, context): + mock_service.process_fhir_document_reference.side_effect = DocumentRefException( + 400, LambdaError.DocRefNoParse + ) + + response = lambda_handler(MOCK_MTLS_VALID_EVENT, context) + assert response["statusCode"] == 400 + assert "body" in response + body = json.loads(response["body"]) + assert body["resourceType"] == "OperationOutcome" + assert body["issue"][0]["details"]["coding"][0]["code"] == "VALIDATION_ERROR" + assert ( + body["issue"][0]["diagnostics"] + == "Failed to parse document upload request data" + ) + + +def test_lambda_handler_internal_server_error(set_env, mock_service, context): + mock_service.process_fhir_document_reference.side_effect = DocumentRefException( + 500, LambdaError.InternalServerError + ) + + response = lambda_handler(MOCK_MTLS_VALID_EVENT, context) + assert response["statusCode"] == 500 + assert "body" in response + body = json.loads(response["body"]) + assert body["resourceType"] == "OperationOutcome" + assert body["issue"][0]["details"]["coding"][0]["code"] == "exception" + assert body["issue"][0]["diagnostics"] == "An internal server error occurred" diff --git a/lambdas/tests/unit/services/test_delete_fhir_document_reference_service.py b/lambdas/tests/unit/services/test_delete_fhir_document_reference_service.py new file mode 100644 index 000000000..249627301 --- /dev/null +++ b/lambdas/tests/unit/services/test_delete_fhir_document_reference_service.py @@ -0,0 +1,60 @@ +from unittest.mock import MagicMock, patch + +import pytest +from enums.snomed_codes import SnomedCodes +from enums.supported_document_types import SupportedDocumentTypes +from models.document_reference import DocumentReference +from services.delete_fhir_document_reference_service import ( + DeleteFhirDocumentReferenceService, +) +from tests.unit.helpers.data.dynamo.dynamo_responses import MOCK_SEARCH_RESPONSE + +MOCK_DOCUMENT_REFERENCE = DocumentReference.model_validate( + MOCK_SEARCH_RESPONSE["Items"][0] +) + + +@pytest.fixture +def service(): + return DeleteFhirDocumentReferenceService() + + +def test_process_calls_handle_reference_delete_for_non_pdm(service): + event = {"requestContext": {}} + deletion_identifiers = ["3d8683b9-1665-40d2-8499-6e8302d507ff", "9000000009"] + + with patch( + "services.delete_fhir_document_reference_service.validate_common_name_in_mtls", + return_value="CN", + ), patch.object( + service, + "extract_parameters", + return_value=deletion_identifiers, + ), patch.object( + service, + "_determine_document_type_based_on_common_name", + return_value=SnomedCodes.LLOYD_GEORGE.value, + ), patch.object( + service, + "is_uuid", + return_value=True, + ), patch( + "services.delete_fhir_document_reference_service.DocumentDeletionService" + ) as mock_deletion_service_cls: + + mock_deletion_service = MagicMock() + mock_deletion_service.handle_reference_delete.return_value = [ + MOCK_DOCUMENT_REFERENCE + ] + mock_deletion_service_cls.return_value = mock_deletion_service + + result = service.process_fhir_document_reference(event) + + assert result == [MOCK_DOCUMENT_REFERENCE] + + mock_deletion_service.handle_reference_delete.assert_called_once_with( + deletion_identifiers[1], + [SupportedDocumentTypes.LG], + document_id=deletion_identifiers[0], + fhir=True, + ) diff --git a/lambdas/tests/unit/services/test_document_deletion_service.py b/lambdas/tests/unit/services/test_document_deletion_service.py index eb83ea760..66e6d57aa 100644 --- a/lambdas/tests/unit/services/test_document_deletion_service.py +++ b/lambdas/tests/unit/services/test_document_deletion_service.py @@ -462,6 +462,22 @@ def test_handle_reference_delete_single_document_not_found_raises_exception( assert excinfo.value.status_code == 404 +def test_handle_reference_delete_single_document_not_found_returns_empty_list_for_fhir( + mock_deletion_service, mocker +): + mocker.patch.object( + mock_deletion_service.document_service, + "fetch_document_from_table", + return_value=[], + ) + + result = mock_deletion_service.handle_reference_delete( + "mock_nhs_number", [], "mock_document_id", fhir=True + ) + + assert result == [] + + def test_delete_specific_doc_type_client_error_raises_exception( mock_deletion_service, mocker ): diff --git a/lambdas/tests/unit/services/test_document_service.py b/lambdas/tests/unit/services/test_document_service.py index 87b603050..9a15798e3 100644 --- a/lambdas/tests/unit/services/test_document_service.py +++ b/lambdas/tests/unit/services/test_document_service.py @@ -203,6 +203,36 @@ def test_delete_documents_soft_delete(mock_service, mock_dynamo_service): ) +@freeze_time("2023-10-1 13:00:00") +def test_delete_documents_soft_delete_core(mock_service, mock_dynamo_service): + test_doc_ref = DocumentReference.model_validate(MOCK_DOCUMENT) + + test_date = datetime.now() + ttl_date = test_date + timedelta(days=float(DocumentRetentionDays.SOFT_DELETE)) + + test_update_fields = { + "Deleted": datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "TTL": int(ttl_date.timestamp()), + "DocStatus": "deprecated", + } + + mock_service.delete_document_reference( + "dev_COREDocumentMetadata", + test_doc_ref, + DocumentRetentionDays.SOFT_DELETE, + { + DocumentReferenceMetadataFields.ID.value: test_doc_ref.id, + DocumentReferenceMetadataFields.NHS_NUMBER.value: test_doc_ref.nhs_number, + }, + ) + + mock_dynamo_service.update_item.assert_called_once_with( + table_name="dev_COREDocumentMetadata", + key_pair={"ID": test_doc_ref.id, "NhsNumber": test_doc_ref.nhs_number}, + updated_fields=test_update_fields, + ) + + @freeze_time("2023-10-1 13:00:00") def test_delete_documents_death_delete(mock_service, mock_dynamo_service): test_doc_ref = DocumentReference.model_validate(MOCK_DOCUMENT) diff --git a/lambdas/tests/unit/services/test_pdm_delete_fhir_document_reference_by_nhs_id_service.py b/lambdas/tests/unit/services/test_pdm_delete_fhir_document_reference_by_nhs_id_service.py new file mode 100644 index 000000000..938f714e0 --- /dev/null +++ b/lambdas/tests/unit/services/test_pdm_delete_fhir_document_reference_by_nhs_id_service.py @@ -0,0 +1,378 @@ +import uuid +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +from lambdas.models import document_reference +import pytest +from botocore.exceptions import ClientError +from enums.document_retention import DocumentRetentionDays +from enums.metadata_field_names import DocumentReferenceMetadataFields +from enums.mtls import MtlsCommonNames +from enums.snomed_codes import SnomedCodes +from models.document_reference import DocumentReference, Identifier +from services.delete_fhir_document_reference_service import ( + DeleteFhirDocumentReferenceService, +) +from tests.unit.conftest import TEST_NHS_NUMBER +from tests.unit.helpers.data.dynamo.dynamo_responses import MOCK_SEARCH_RESPONSE +from utils.common_query_filters import NotDeleted +from utils.lambda_exceptions import ( + DocumentRefException, +) + +MOCK_DOCUMENT_REFERENCE = DocumentReference.model_validate( + MOCK_SEARCH_RESPONSE["Items"][0] +) + +TEST_DOCUMENT_ID = "3d8683b9-1665-40d2-8499-6e8302d507ff" + +MOCK_MTLS_VALID_EVENT_BY_NHS_ID = { + "httpMethod": "DELETE", + "headers": {}, + "queryStringParameters": { + "subject:identifier": f"https://fhir.nhs.uk/Id/nhs-number|{TEST_NHS_NUMBER}", + "_id": TEST_DOCUMENT_ID, + }, + "body": None, + "requestContext": { + "accountId": "123456789012", + "apiId": "abc123", + "domainName": "api.example.com", + "identity": { + "sourceIp": "1.2.3.4", + "userAgent": "curl/7.64.1", + "clientCert": { + "clientCertPem": "-----BEGIN CERTIFICATE-----...", + "subjectDN": "CN=ndrclient.main.int.pdm.national.nhs.uk,O=NHS,C=UK", + "issuerDN": "CN=NHS Root CA,O=NHS,C=UK", + "serialNumber": "12:34:56", + "validity": { + "notBefore": "May 10 00:00:00 2024 GMT", + "notAfter": "May 10 00:00:00 2025 GMT", + }, + }, + }, + }, +} + +MOCK_MTLS_VALID_EVENT_WITH_INVALID_PATH_PARAM = { + "httpMethod": "DELETE", + "headers": {}, + "pathParameters": {"foo": f"{TEST_NHS_NUMBER}"}, + "body": None, +} + +MOCK_MTLS_VALID_EVENT_WITH_VALID_PATH_PARAM_NO_QUERY_PARAM = { + "httpMethod": "DELETE", + "headers": {}, + "pathParameters": {"id": f"{TEST_DOCUMENT_ID}"}, + "body": None, +} + +MOCK_MTLS_VALID_EVENT_WITH_INVALID_QUERY_PARAM = { + "httpMethod": "DELETE", + "headers": {}, + "queryStringParameters": { + "foo": f"https://fhir.nhs.uk/Id/nhs-number|{TEST_NHS_NUMBER}" + }, + "body": None, +} + +MOCK_MTLS_VALID_EVENT_WITH_INVALID_QUERY_PARAM_AND_VALID_QUERY_PARAMS = { + "httpMethod": "DELETE", + "headers": {}, + "queryStringParameters": { + "foo": f"https://fhir.nhs.uk/Id/nhs-number|{TEST_NHS_NUMBER}", + "subject:identifier": f"https://fhir.nhs.uk/Id/nhs-number|{TEST_NHS_NUMBER}", + "_id": TEST_DOCUMENT_ID, + }, + "body": None, +} + +MOCK_MTLS_INVALID_EVENT = { + "httpMethod": "DELETE", + "headers": {}, + "body": None, +} + +MOCK_MTLS_INVALID_EVENT_PATH_AND_QUERY = { + "httpMethod": "DELETE", + "headers": {}, + "queryStringParameters": { + "subject:identifier": f"https://fhir.nhs.uk/Id/nhs-number|{TEST_NHS_NUMBER}" + }, + "pathParameters": {"id": f"{TEST_DOCUMENT_ID}"}, + "body": None, +} + + +@pytest.fixture +def service(): + return DeleteFhirDocumentReferenceService() + + +def test_valid_uuid(service): + assert service.is_uuid(str(uuid.uuid4())) is True + + +def test_invalid_uuid(service): + assert service.is_uuid("not-a-uuid") is False + + +def test_none(service): + assert service.is_uuid(None) is False + + +def test_extract_path_parameter_no_path_param_exists_for_id(service): + identifier = service.extract_document_path_parameters( + MOCK_MTLS_VALID_EVENT_BY_NHS_ID + ) + assert identifier is None + + +def test_extract_path_parameter_no_path_param_exists_for_id_but_pathParameters_exist( + service, +): + identifier = service.extract_document_path_parameters( + MOCK_MTLS_VALID_EVENT_WITH_INVALID_PATH_PARAM + ) + assert identifier is None + + +def test_extract_path_parameter_path_param_exists_for_id(service): + identifier = service.extract_document_path_parameters( + MOCK_MTLS_VALID_EVENT_WITH_VALID_PATH_PARAM_NO_QUERY_PARAM + ) + assert identifier is TEST_DOCUMENT_ID + + +def test_extract_query_parameter_for_id_and_nhsnumber(service): + identifiers = service.extract_document_query_parameters( + MOCK_MTLS_VALID_EVENT_BY_NHS_ID["queryStringParameters"] + ) + assert identifiers[0] == TEST_NHS_NUMBER + assert identifiers[1] == TEST_DOCUMENT_ID + + +def test_extract_query_parameter_with_invalid_query_parameter(service): + identifiers = service.extract_document_query_parameters( + MOCK_MTLS_VALID_EVENT_WITH_INVALID_QUERY_PARAM["queryStringParameters"] + ) + assert identifiers == (None, None) + + +def test_extract_query_parameter_when_non_existent(service): + identifiers = service.extract_document_query_parameters( + MOCK_MTLS_VALID_EVENT_WITH_VALID_PATH_PARAM_NO_QUERY_PARAM + ) + assert identifiers == (None, None) + + +def test_extract_query_parameter_with_too_many(service): + identifiers = service.extract_document_query_parameters( + MOCK_MTLS_VALID_EVENT_WITH_INVALID_QUERY_PARAM_AND_VALID_QUERY_PARAMS + ) + assert identifiers == (None, None) + + +def test_extract_parameters_when_query_but_no_path(service): + identifiers = service.extract_parameters(MOCK_MTLS_VALID_EVENT_BY_NHS_ID) + assert identifiers[0] == TEST_DOCUMENT_ID + assert identifiers[1] == TEST_NHS_NUMBER + + +def test_extract_parameters_when_no_query_and_no_path(service): + with pytest.raises(DocumentRefException) as exc_info: + service.extract_parameters(MOCK_MTLS_INVALID_EVENT) + + assert exc_info.value.status_code == 400 + + +def test_doc_type_by_common_name_pdm(service): + doc_type = service._determine_document_type_based_on_common_name( + MtlsCommonNames.PDM + ) + assert doc_type.code == SnomedCodes.PATIENT_DATA.value.code + + +def test_doc_type_by_common_name_None(service): + doc_type = service._determine_document_type_based_on_common_name(None) + assert doc_type.code == SnomedCodes.LLOYD_GEORGE.value.code + + +def test_delete_fhir_document_references_by_nhs_id_and_doc_id_happy_path(service): + nhs_number = "9000000009" + doc_type = MagicMock() + doc_type.value = "test-doc-type" + + mock_document = MOCK_DOCUMENT_REFERENCE + dynamo_table = "mock-table" + + service._get_dynamo_table_for_doc_type = MagicMock(return_value=dynamo_table) + + service.delete_document_reference = MagicMock() + + with patch( + "services.delete_fhir_document_reference_service.DocumentService" + ) as mock_document_service_cls: + + mock_document_service = MagicMock() + mock_document_service.get_item_agnostic.return_value = mock_document + mock_document_service_cls.return_value = mock_document_service + + result = service.delete_fhir_document_reference_by_nhs_id_and_doc_id( + nhs_number=nhs_number, + doc_id=TEST_DOCUMENT_ID, + doc_type=doc_type, + ) + + assert result == mock_document + + service._get_dynamo_table_for_doc_type.assert_called_once_with(doc_type) + + mock_document_service.get_item_agnostic.assert_called_once_with( + partion_key={DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs_number}, + sort_key={DocumentReferenceMetadataFields.ID.value: TEST_DOCUMENT_ID}, + table_name=dynamo_table, + ) + + mock_document_service.delete_document_reference.assert_called_once_with( + table_name=dynamo_table, + document_reference=mock_document, + document_ttl_days=DocumentRetentionDays.SOFT_DELETE, + key_pair={ + DocumentReferenceMetadataFields.NHS_NUMBER.value: nhs_number, + DocumentReferenceMetadataFields.ID.value: TEST_DOCUMENT_ID, + }, + ) + + +def test_delete_fhir_document_references_by_nhs_id_no_documents(service): + service._get_dynamo_table_for_doc_type = MagicMock(return_value="mock-table") + + service.delete_document_reference = MagicMock() + + with patch( + "services.delete_fhir_document_reference_service.DocumentService" + ) as mock_document_service_cls: + + mock_document_service = MagicMock() + mock_document_service.get_item_agnostic.return_value = [] + mock_document_service_cls.return_value = mock_document_service + + result = service.delete_fhir_document_reference_by_nhs_id_and_doc_id( + nhs_number="9000000009", + doc_id=TEST_DOCUMENT_ID, + doc_type=MagicMock(), + ) + + assert result is None + service.delete_document_reference.assert_not_called() + + +def test_delete_fhir_document_references_by_nhs_id_propagates_client_error(service): + service._get_dynamo_table_for_doc_type = MagicMock(return_value="mock-table") + + with patch( + "services.delete_fhir_document_reference_service.DocumentService" + ) as mock_document_service_cls: + + mock_document_service = MagicMock() + mock_document_service.get_item_agnostic.side_effect = ClientError( + error_response={}, operation_name="Query" + ) + mock_document_service_cls.return_value = mock_document_service + + with pytest.raises(ClientError): + service.delete_fhir_document_reference_by_nhs_id_and_doc_id( + nhs_number="9000000009", + doc_id=TEST_DOCUMENT_ID, + doc_type=MagicMock(), + ) + + +def test_process_calls_delete_by_nhs_id_for_pdm(service): + event = {"requestContext": {}} + deletion_identifiers = [TEST_DOCUMENT_ID, "9000000009"] + + with patch( + "services.delete_fhir_document_reference_service.validate_common_name_in_mtls", + return_value="CN", + ), patch.object( + service, + "extract_parameters", + return_value=deletion_identifiers, + ), patch.object( + service, + "_determine_document_type_based_on_common_name", + return_value=SnomedCodes.PATIENT_DATA.value, + ), patch.object( + service, + "is_uuid", + return_value=True, + ), patch.object( + service, + "delete_fhir_document_reference_by_nhs_id_and_doc_id", + return_value=MOCK_DOCUMENT_REFERENCE, + ) as mock_delete: + + result = service.process_fhir_document_reference(event) + + assert result == MOCK_DOCUMENT_REFERENCE + + mock_delete.assert_called_once_with( + nhs_number=deletion_identifiers[1], + doc_id=deletion_identifiers[0], + doc_type=SnomedCodes.PATIENT_DATA.value, + ) + + +def test_process_returns_none_when_only_one_identifier(service): + event = {"requestContext": {}} + + with patch( + "services.delete_fhir_document_reference_service.validate_common_name_in_mtls", + return_value="CN", + ), patch.object( + service, + "extract_parameters", + return_value=[TEST_DOCUMENT_ID], + ), patch.object( + service, + "_determine_document_type_based_on_common_name", + return_value=SnomedCodes.PATIENT_DATA.value, + ), patch.object( + service, + "is_uuid", + return_value=False, + ): + + result = service.process_fhir_document_reference(event) + + assert result == [] + + +def test_process_returns_none_when_identifiers_are_none(service): + event = {"requestContext": {}} + + with patch( + "services.delete_fhir_document_reference_service.validate_common_name_in_mtls", + return_value="CN", + ), patch.object( + service, + "extract_parameters", + return_value=[None, None], + ), patch.object( + service, + "_determine_document_type_based_on_common_name", + return_value=SnomedCodes.PATIENT_DATA.value, + ), patch.object( + service, + "is_uuid", + return_value=False, + ): + + with pytest.raises(DocumentRefException) as exc_info: + service.process_fhir_document_reference(event) + + assert exc_info.value.status_code == 400 diff --git a/lambdas/tests/unit/utils/test_error_testing_utils.py b/lambdas/tests/unit/utils/test_error_testing_utils.py index 4829a6527..f0b99c9f8 100644 --- a/lambdas/tests/unit/utils/test_error_testing_utils.py +++ b/lambdas/tests/unit/utils/test_error_testing_utils.py @@ -41,12 +41,10 @@ def test_check_manual_error_conditions_500(mocker): def test_trigger_400(): expected_status_code = 400 - expected_body = "" response = trigger_400("GET") actual_status_code = response["statusCode"] - actual_body = response["body"] + assert "body" not in response assert actual_status_code == expected_status_code - assert actual_body == expected_body diff --git a/lambdas/utils/lambda_exceptions.py b/lambdas/utils/lambda_exceptions.py index 9a30812ee..8e2766b8d 100644 --- a/lambdas/utils/lambda_exceptions.py +++ b/lambdas/utils/lambda_exceptions.py @@ -98,6 +98,10 @@ class GetFhirDocumentReferenceException(LambdaException): pass +class DeleteFhirDocumentReferenceException(LambdaException): + pass + + class OdsReportException(LambdaException): pass diff --git a/lambdas/utils/lambda_response.py b/lambdas/utils/lambda_response.py index 08a145f3a..418ad5bd5 100644 --- a/lambdas/utils/lambda_response.py +++ b/lambdas/utils/lambda_response.py @@ -1,5 +1,5 @@ class ApiGatewayResponse: - def __init__(self, status_code: int, body: str, methods: str) -> None: + def __init__(self, status_code: int, body: str | None = None, methods: str = "POST") -> None: self.status_code = status_code self.body = body self.methods = methods @@ -7,7 +7,7 @@ def __init__(self, status_code: int, body: str, methods: str) -> None: def create_api_gateway_response(self, headers=None) -> dict: if headers is None: headers = {} - return { + response = { "isBase64Encoded": False, "statusCode": self.status_code, "headers": { @@ -17,12 +17,10 @@ def create_api_gateway_response(self, headers=None) -> dict: "Strict-Transport-Security": "max-age=63072000", **headers, }, - "body": self.body, } + if self.body: + response["body"] = self.body + return response def __eq__(self, other): - return ( - self.status_code == other.status_code - and self.body == other.body - and self.methods == other.methods - ) + return self.status_code == other.status_code and self.body == other.body and self.methods == other.methods diff --git a/scripts/build_and_deploy_sandbox.sh b/scripts/build_and_deploy_sandbox.sh index ede9f0f10..33fb7bbe3 100755 --- a/scripts/build_and_deploy_sandbox.sh +++ b/scripts/build_and_deploy_sandbox.sh @@ -16,6 +16,7 @@ NDR_WORKFLOW_FILE_FULL="full-deploy-to-sandbox.yml" FULL_DEPLOY=false SANDBOX_NAME="" START_TIME="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +SKIP_MAIN=false spinner=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) spin_i=0 @@ -50,6 +51,9 @@ for arg in "$@"; do --ndri_dir_loc_override=*) NDRI_DIRECTORY="${arg#*=}" ;; + --skip_main=*) + SKIP_MAIN="${arg#*=}" + ;; *) echo "Unknown argument: $arg" echo "Usage: $0 [--ndri_workflow_branch=] [--ndri_branch=] [--ndr_branch=] [--ndr_workflow_branch=] [--sandbox_name=] [--build_infra=] [--ndri_dir_loc_override=]" @@ -78,16 +82,16 @@ if [[ "$BUILD_INFRA" == "true" ]]; then cd "$NDRI_DIRECTORY" echo "🔁 Triggering infrastructure workflow '$NDRI_WORKFLOW_FILE' from '$NDRI_WORKFLOW_BRANCH' with branch '$NDRI_BRANCH' to '$SANDBOX_NAME'..." # Trigger the workflow and capture the run ID - gh workflow run "$NDRI_WORKFLOW_FILE" --ref "$NDRI_WORKFLOW_BRANCH" --field git_ref="$NDRI_BRANCH" --field sandbox_name="$SANDBOX_NAME" >/dev/null + gh workflow run "$NDRI_WORKFLOW_FILE" --ref "$NDRI_WORKFLOW_BRANCH" --field git_ref="$NDRI_BRANCH" --field sandbox_name="$SANDBOX_NAME" --field skip_main_deployment="$SKIP_MAIN" >/dev/null - for i in {1..10}; do + for i in {1..20}; do run_id=$( gh run list \ --workflow "$NDRI_WORKFLOW_FILE" \ --event workflow_dispatch \ --json status,databaseId,createdAt,displayTitle \ --jq ".[] - | select(.displayTitle == \"$NDRI_WORKFLOW_BRANCH | $SANDBOX_NAME\") + | select(.displayTitle | contains(\"$NDRI_BRANCH | $SANDBOX_NAME\")) | select(.createdAt >= \"$START_TIME\") | select(.status == \"queued\" or .status == \"in_progress\") | .databaseId" | @@ -95,7 +99,7 @@ if [[ "$BUILD_INFRA" == "true" ]]; then ) [[ -n "$run_id" ]] && break - sleep 1 + sleep 2 done if [[ -z "$run_id" ]]; then @@ -164,7 +168,7 @@ else WORKFLOW_FILE="$NDR_WORKFLOW_FILE" fi -for i in {1..10}; do +for i in {1..20}; do lambda_run_id=$( gh run list \ --workflow "$WORKFLOW_FILE" \ @@ -179,7 +183,7 @@ for i in {1..10}; do ) [[ -n "$lambda_run_id" ]] && break - sleep 1 + sleep 2 done if [[ -z "$lambda_run_id" ]]; then