diff --git a/api/producer/createDocumentReference/create_document_reference.py b/api/producer/createDocumentReference/create_document_reference.py index 4e0deba1c..1fe2aeadf 100644 --- a/api/producer/createDocumentReference/create_document_reference.py +++ b/api/producer/createDocumentReference/create_document_reference.py @@ -254,7 +254,10 @@ def handler( return error_response can_ignore_delete_fail = ( - PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL in metadata.nrl_permissions + AccessControls.ALLOW_SUPERSEDE_WITH_DELETE_FAILURE.value + in metadata.nrl_permissions_policy.access_controls + if metadata.nrl_permissions_policy + else PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL in metadata.nrl_permissions ) if ids_to_delete := _get_document_ids_to_supersede( diff --git a/api/producer/createDocumentReference/tests/test_create_document_reference.py b/api/producer/createDocumentReference/tests/test_create_document_reference.py index 079069f97..b92cd4922 100644 --- a/api/producer/createDocumentReference/tests/test_create_document_reference.py +++ b/api/producer/createDocumentReference/tests/test_create_document_reference.py @@ -1435,7 +1435,7 @@ def test_create_document_reference_supersede_deletes_old_pointers_replace( @mock_aws @mock_repository @freeze_uuid("00000000-0000-0000-0000-000000000001") -def test_create_document_reference_supersede_succeeds_with_toggle( +def test_supersede_non_existent_pointer_succeeds_with_v1_ignore_delete_fail( repository: DocumentPointerRepository, ): doc_ref = load_document_reference("Y05868-736253002-Valid") @@ -1493,7 +1493,7 @@ def test_create_document_reference_supersede_succeeds_with_toggle( @mock_aws @mock_repository -def test_create_document_reference_supersede_fails_without_toggle( +def test_supersede_non_existent_pointer_fails_without_v1_ignore_delete_fail( repository: DocumentPointerRepository, ): doc_ref = load_document_reference("Y05868-736253002-Valid") @@ -1544,6 +1544,143 @@ def test_create_document_reference_supersede_fails_without_toggle( } +@mock_aws +@mock_repository +@freeze_uuid("00000000-0000-0000-0000-000000000001") +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_supersede_non_existent_pointer_succeeds_with_v2_access_control( + get_pointer_permissions_mock, repository: DocumentPointerRepository +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + + # Add reference to a non-existing pointer + doc_ref.relatesTo = [ + DocumentReferenceRelatesTo( + code="replaces", + target=Reference(identifier=Identifier(value="Y05868-99999-99999-000000")), + ) + ] + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [AccessControls.ALLOW_SUPERSEDE_WITH_DELETE_FAILURE.value], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + body=doc_ref.model_dump_json(exclude_none=True), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "201", + "headers": { + "Location": "/DocumentReference/Y05868-00000000-0000-0000-0000-000000000001", + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "code": "RESOURCE_SUPERSEDED", + "display": "Resource created and resource(s) deleted", + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + } + ] + }, + "diagnostics": "The document has been superseded by a new version", + } + ], + } + + +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_supersede_fails_without_v2_access_control( + get_pointer_permissions_mock, repository: DocumentPointerRepository +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + + # Add reference to a non-existing pointer + doc_ref.relatesTo = [ + DocumentReferenceRelatesTo( + code="replaces", + target=Reference(identifier=Identifier(value="Y05868-99999-99999-000000")), + ) + ] + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + body=doc_ref.model_dump_json(exclude_none=True), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "422", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "business-rule", + "details": { + "coding": [ + { + "code": "UNPROCESSABLE_ENTITY", + "display": "Unprocessable Entity", + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + } + ] + }, + "diagnostics": "The relatesTo target document does not exist", + "expression": ["relatesTo[0].target.identifier.value"], + } + ], + } + + @mock_aws @mock_repository @freeze_uuid("00000000-0000-0000-0000-000000000001") diff --git a/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py b/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py index 10f971db4..f359a5f4b 100644 --- a/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py +++ b/api/producer/upsertDocumentReference/tests/test_upsert_document_reference.py @@ -1411,7 +1411,7 @@ def test_upsert_document_reference_supersede_deletes_old_pointers_replace( @mock_aws @mock_repository -def test_upsert_document_reference_supersede_succeeds_with_toggle( +def test_supersede_non_existent_pointer_succeeds_with_v1_ignore_delete_fail( repository: DocumentPointerRepository, ): doc_ref = load_document_reference("Y05868-736253002-Valid") @@ -1463,13 +1463,10 @@ def test_upsert_document_reference_supersede_succeeds_with_toggle( ], } - non_existent_pointer = repository.get_by_id("Y05868-99999-99999-000000") - assert non_existent_pointer is None - @mock_aws @mock_repository -def test_upsert_document_reference_supersede_fails_without_toggle( +def test_supersede_non_existent_pointer_fails_without_v1_ignore_delete_fail( repository: DocumentPointerRepository, ): doc_ref = load_document_reference("Y05868-736253002-Valid") @@ -1520,6 +1517,142 @@ def test_upsert_document_reference_supersede_fails_without_toggle( } +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_supersede_non_existent_pointer_succeeds_with_v2_access_control( + get_pointer_permissions_mock, repository: DocumentPointerRepository +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + + # Add reference to a non-existing pointer + doc_ref.relatesTo = [ + DocumentReferenceRelatesTo( + code="replaces", + target=Reference(identifier=Identifier(value="Y05868-99999-99999-000000")), + ) + ] + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [AccessControls.ALLOW_SUPERSEDE_WITH_DELETE_FAILURE.value], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + body=doc_ref.model_dump_json(exclude_none=True), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "201", + "headers": { + "Location": "/DocumentReference/Y05868-99999-99999-999999", + **default_response_headers(), + }, + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "code": "RESOURCE_SUPERSEDED", + "display": "Resource created and resource(s) deleted", + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + } + ] + }, + "diagnostics": "The document has been superseded by a new version", + } + ], + } + + +@mock_aws +@mock_repository +@patch("nrlf.core.decorators.get_pointer_permissions_v2") +def test_supersede_fails_without_v2_access_control( + get_pointer_permissions_mock, repository: DocumentPointerRepository +): + doc_ref = load_document_reference("Y05868-736253002-Valid") + + # Add reference to a non-existing pointer + doc_ref.relatesTo = [ + DocumentReferenceRelatesTo( + code="replaces", + target=Reference(identifier=Identifier(value="Y05868-99999-99999-000000")), + ) + ] + + v2_headers = create_headers( + additional_headers={ + V2Headers.NHSD_END_USER_ORGANISATION_ODS: "Y05868", + V2Headers.NHSD_NRL_APP_ID: "Y05868-TestApp-12345678", + } + ) + v2_headers.pop(CLIENT_RP_DETAILS) + + get_pointer_permissions_mock.return_value = { + "access_controls": [], + "types": ["http://snomed.info/sct|736253002"], + } + + event = create_test_api_gateway_event( + headers=v2_headers, + body=doc_ref.model_dump_json(exclude_none=True), + ) + + result = handler(event, create_mock_context()) + body = result.pop("body") + + assert result == { + "statusCode": "422", + "headers": default_response_headers(), + "isBase64Encoded": False, + } + + parsed_body = json.loads(body) + + assert parsed_body == { + "resourceType": "OperationOutcome", + "issue": [ + { + "severity": "error", + "code": "business-rule", + "details": { + "coding": [ + { + "code": "UNPROCESSABLE_ENTITY", + "display": "Unprocessable Entity", + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + } + ] + }, + "diagnostics": "The relatesTo target document does not exist", + "expression": ["relatesTo[0].target.identifier.value"], + } + ], + } + + @mock_aws @mock_repository def test_upsert_document_reference_create_relatesto_not_replaces( diff --git a/api/producer/upsertDocumentReference/upsert_document_reference.py b/api/producer/upsertDocumentReference/upsert_document_reference.py index c8b5a122c..dd9e87c6c 100644 --- a/api/producer/upsertDocumentReference/upsert_document_reference.py +++ b/api/producer/upsertDocumentReference/upsert_document_reference.py @@ -258,7 +258,10 @@ def handler( return error_response can_ignore_delete_fail = ( - PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL in metadata.nrl_permissions + AccessControls.ALLOW_SUPERSEDE_WITH_DELETE_FAILURE.value + in metadata.nrl_permissions_policy.access_controls + if metadata.nrl_permissions_policy + else PERMISSION_SUPERSEDE_IGNORE_DELETE_FAIL in metadata.nrl_permissions ) if ids_to_delete := _get_document_ids_to_supersede( diff --git a/layer/nrlf/core/constants.py b/layer/nrlf/core/constants.py index 9ef41b69c..2fa26e088 100644 --- a/layer/nrlf/core/constants.py +++ b/layer/nrlf/core/constants.py @@ -58,6 +58,7 @@ class AccessControls(Enum): ALLOW_PRODUCE_FOR_ANY_AUTHOR = "allow_produce_for_any_author" ALLOW_PRODUCE_FOR_ANY_CUSTODIAN = "allow_produce_for_any_custodian" ALLOW_OVERRIDE_CREATION_DATETIME = "allow_override_creation_datetime" + ALLOW_SUPERSEDE_WITH_DELETE_FAILURE = "allow_supersede_with_delete_failure" @staticmethod def list(): diff --git a/scripts/get_s3_permissions.py b/scripts/get_s3_permissions.py index dbf2f2538..66f39c287 100644 --- a/scripts/get_s3_permissions.py +++ b/scripts/get_s3_permissions.py @@ -103,6 +103,7 @@ def add_feature_test_files(local_path): [ AccessControls.ALLOW_ALL_TYPES.value, AccessControls.ALLOW_OVERRIDE_CREATION_DATETIME.value, + AccessControls.ALLOW_SUPERSEDE_WITH_DELETE_FAILURE.value, ], ), ], diff --git a/tests/features/environment.py b/tests/features/environment.py index 66249009b..ad7c4fc3d 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -10,6 +10,42 @@ from nrlf.core.dynamodb.repository import DocumentPointerRepository +def before_scenario(context: Context, scenario): + """ + Called before each scenario unless the the resources (i.e. the DynamoDB tables) are shared for the stack. + Deletes every item in the table so that leftover data created by previous runs doesn't affect the current + scenario. Documents set up in 'Given' steps are re-created fresh after this cleanup. + """ + if context.is_shared_resources: + return + + try: + scan_kwargs = {"ProjectionExpression": "pk, sk"} + keys_to_delete = [] + while True: + response = context.repository.table.scan(**scan_kwargs) + keys_to_delete.extend( + {"pk": item["pk"], "sk": item["sk"]} + for item in response.get("Items", []) + ) + last_key = response.get("LastEvaluatedKey") + if not last_key: + break + scan_kwargs["ExclusiveStartKey"] = last_key + + for i in range(0, len(keys_to_delete), 25): + context.repository.table.meta.client.batch_write_item( + RequestItems={ + context.repository.table_name: [ + {"DeleteRequest": {"Key": key}} + for key in keys_to_delete[i : i + 25] + ] + } + ) + except Exception: + pass + + def before_all(context: Context): """ This function is called before all the tests are executed diff --git a/tests/features/producer/upsertDocumentReference-failure.feature b/tests/features/producer/upsertDocumentReference-failure.feature index 192b6439a..fec9bd07e 100644 --- a/tests/features/producer/upsertDocumentReference-failure.feature +++ b/tests/features/producer/upsertDocumentReference-failure.feature @@ -6,7 +6,7 @@ Feature: Producer - upsertDocumentReference - Failure Scenarios | system | value | | http://snomed.info/sct | 1363501000000100 | | http://snomed.info/sct | 736253002 | - When producer 'X26' upserts a DocumentReference with values: + When producer v1 'X26' upserts a DocumentReference with values: | property | value | | id | X26-testid-upsert-0001-0001 | | subject | 9999999999 | @@ -45,7 +45,7 @@ Feature: Producer - upsertDocumentReference - Failure Scenarios | system | value | | http://snomed.info/sct | 1363501000000100 | | http://snomed.info/sct | 736253002 | - When producer 'X26' upserts a DocumentReference with values: + When producer v1 'X26' upserts a DocumentReference with values: | property | value | | subject | 9999999999 | | type | 736253002 | @@ -82,7 +82,7 @@ Feature: Producer - upsertDocumentReference - Failure Scenarios And the organisation 'ANGY1' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When producer 'ANGY1' upserts a DocumentReference with values: + When producer v1 'ANGY1' upserts a DocumentReference with values: | property | value | | id | X26-testid-upsert-0001-0001 | | subject | 9278693472 | @@ -120,7 +120,7 @@ Feature: Producer - upsertDocumentReference - Failure Scenarios And the organisation 'ANGY1' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When producer 'ANGY1' upserts a DocumentReference with values: + When producer v1 'ANGY1' upserts a DocumentReference with values: | property | value | | id | X26-testid-upsert-0001-0001 | | subject | 9999999999 | @@ -161,7 +161,7 @@ Feature: Producer - upsertDocumentReference - Failure Scenarios | system | value | | http://snomed.info/sct | 1363501000000100 | | http://snomed.info/sct | 736253002 | - When producer 'X26' upserts a DocumentReference with values: + When producer v1 'X26' upserts a DocumentReference with values: | property | value | | id | X26-testid-upsert-0001-0001 | | subject | 9999999999 | @@ -200,7 +200,7 @@ Feature: Producer - upsertDocumentReference - Failure Scenarios | system | value | | https://nicip.nhs.uk | MAULR | | https://nicip.nhs.uk | MAXIB | - When producer 'ANGY1' upserts a DocumentReference with values: + When producer v1 'ANGY1' upserts a DocumentReference with values: | property | value | | id | ANGY1-testid-upsert-0001-0001 | | subject | 9999999999 | @@ -335,7 +335,7 @@ Feature: Producer - upsertDocumentReference - Failure Scenarios And the organisation 'ANGY1' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When producer 'ANGY1' upserts a DocumentReference with values: + When producer v1 'ANGY1' upserts a DocumentReference with values: | property | value | | id | TSTCUS-sample-id-00003 | | subject | 9999999999 | diff --git a/tests/features/producer/upsertDocumentReference-success.feature b/tests/features/producer/upsertDocumentReference-success.feature index 9fefb7e66..2492ec602 100644 --- a/tests/features/producer/upsertDocumentReference-success.feature +++ b/tests/features/producer/upsertDocumentReference-success.feature @@ -8,7 +8,7 @@ Feature: Producer - upsertDocumentReference - Success Scenarios And the organisation 'ANGY1' is authorised to access pointer types: | system | value | | http://snomed.info/sct | 736253002 | - When producer 'ANGY1' upserts a DocumentReference with values: + When producer v1 'ANGY1' upserts a DocumentReference with values: | property | value | | id | ANGY1-testid-upsert-0001-0001 | | subject | 9278693472 | @@ -54,7 +54,7 @@ Feature: Producer - upsertDocumentReference - Success Scenarios | system | value | | https://nicip.nhs.uk | MAULR | | https://nicip.nhs.uk | MAXIB | - When producer 'ANGY1' upserts a DocumentReference with values: + When producer v1 'ANGY1' upserts a DocumentReference with values: | property | value | | id | ANGY1-testid-upsert-0001-0001 | | subject | 9278693472 | @@ -95,7 +95,7 @@ Feature: Producer - upsertDocumentReference - Success Scenarios | custodian | ANGY1 | | author | HAR1 | | url | https://example.org/my-doc.pdf | - When producer 'ANGY1' upserts a DocumentReference with values: + When producer v1 'ANGY1' upserts a DocumentReference with values: | property | value | | id | ANGY1-testid-upsert-0001-0002 | | subject | 9278693472 | diff --git a/tests/features/producer/v2-permissions-access-controls.feature b/tests/features/producer/v2-permissions-access-controls.feature index 716bc0966..3ca5246a1 100644 --- a/tests/features/producer/v2-permissions-access-controls.feature +++ b/tests/features/producer/v2-permissions-access-controls.feature @@ -91,3 +91,71 @@ Feature: Producer v2 access_control permissions - Success and Failure Scenarios | url | https://example.org/my-doc.pdf | | practiceSetting | 788002001 | And the date of the resource in the Location header is not '2024-06-01T12:00:00Z' + + Scenario: Successfully supersede a DocumentReference with ALLOW_SUPERSEDE_WITH_DELETE_FAILURE - upsertDocumentReference + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When producer v2 '4LLTYP35P' upserts a DocumentReference with values: + | property | value | + | id | 4LLTYP35P-testid-upsert-0001-0002 | + | subject | 9278693472 | + | status | current | + | type | 736253002 | + | category | 734163000 | + | custodian | 4LLTYP35P | + | author | 4LLTYP35P | + | url | https://example.org/newdoc.pdf | + | supercedes | 4LLTYP35P-000-ThisRefDoesNotExistSupersedeTest | + Then the response status code is 201 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "information", + "code": "informational", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/ValueSet/NRL-ResponseCode", + "code": "RESOURCE_SUPERSEDED", + "display": "Resource created and resource(s) deleted" + } + ] + }, + "diagnostics": "The document has been superseded by a new version" + } + """ + + Scenario: Supersede a DocumentReference fails without ALLOW_SUPERSEDE_WITH_DELETE_FAILURE - createDocumentReference + Given the application 'DataShare' (ID 'z00z-y11y-x22x') is registered to access the API + When producer v2 'RX898' creates a DocumentReference with values: + | property | value | + | subject | 9278693472 | + | status | current | + | type | 736373009 | + | category | 734163000 | + | custodian | RX898 | + | author | RX898 | + | url | https://example.org/newdoc.pdf | + | supercedes | RX898-000-ThisRefDoesNotExistSupersedeTest | + Then the response status code is 422 + And the response is an OperationOutcome with 1 issue + And the OperationOutcome contains the issue: + """ + { + "severity": "error", + "code": "business-rule", + "details": { + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "code": "UNPROCESSABLE_ENTITY", + "display": "Unprocessable Entity" + } + ] + }, + "diagnostics": "The relatesTo target document does not exist", + "expression": [ + "relatesTo[0].target.identifier.value" + ] + } + """ diff --git a/tests/features/steps/2_request.py b/tests/features/steps/2_request.py index 733988d83..50f1b9848 100644 --- a/tests/features/steps/2_request.py +++ b/tests/features/steps/2_request.py @@ -96,10 +96,10 @@ def create_post_document_reference_step(context: Context, version: str, ods_code if context.response.status_code == 201: doc_ref_id = context.response.headers["Location"].split("/")[-1] - doc_ref_id.replace( + doc_ref_id = doc_ref_id.replace( "|", "." ) # NRL-766 define and resolve custodian suffix behaviour - context.add_cleanup(lambda: context.repository.delete_by_id(doc_ref_id)) + context.add_cleanup(lambda id=doc_ref_id: context.repository.delete_by_id(id)) def _create_or_upsert_body_step( @@ -120,10 +120,10 @@ def _create_or_upsert_body_step( if context.response.status_code == 201: doc_ref_id = context.response.headers["Location"].split("/")[-1] - doc_ref_id.replace( + doc_ref_id = doc_ref_id.replace( "|", "." ) # NRL-766 define and resolve custodian suffix behaviour - context.add_cleanup(lambda: context.repository.delete_by_id(doc_ref_id)) + context.add_cleanup(lambda id=doc_ref_id: context.repository.delete_by_id(id)) @when( @@ -182,9 +182,9 @@ def update_post_body_step(context: Context, pointer_id: str): context.response = producer_client.update(doc_ref, pointer_id) -@when("producer '{ods_code}' upserts a DocumentReference with values") -def create_put_document_reference_step(context: Context, ods_code: str): - client = producer_client_from_context(context, ods_code) +@when("producer {version} '{ods_code}' upserts a DocumentReference with values") +def create_put_document_reference_step(context: Context, ods_code: str, version: str): + client = producer_client_from_context(context, ods_code, v2=(version == "v2")) if not context.table: raise ValueError("No document reference data table provided") @@ -196,7 +196,7 @@ def create_put_document_reference_step(context: Context, ods_code: str): context.response = client.upsert(doc_ref.model_dump(exclude_none=True)) if context.response.status_code == 201: - context.add_cleanup(lambda: context.repository.delete_by_id(doc_ref_id)) + context.add_cleanup(lambda id=doc_ref_id: context.repository.delete_by_id(id)) @when("producer '{ods_code}' updates a DocumentReference '{doc_ref_id}' with values") @@ -217,7 +217,7 @@ def update_put_document_reference_step( context.response = client.update(doc_ref, doc_ref_id) if context.response.status_code == 200: - context.add_cleanup(lambda: context.repository.delete_by_id(doc_ref_id)) + context.add_cleanup(lambda id=doc_ref_id: context.repository.delete_by_id(id)) @when(