From c73e5640f629030338a04ed91cc1132558ef59b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paola=20Rodr=C3=ADguez=20Mart=C3=ADnez?= Date: Wed, 24 Dec 2025 13:01:30 -0600 Subject: [PATCH 1/2] Hard deletes inside a transaction bundle should be allowed, but not conditional deletes. --- docs/rest/ConditionalDeleteRequests.http | 41 +++++++++++++++++++ .../Bundle/TransactionBundleValidator.cs | 26 ++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/docs/rest/ConditionalDeleteRequests.http b/docs/rest/ConditionalDeleteRequests.http index 2211c2871e..004f5524ca 100644 --- a/docs/rest/ConditionalDeleteRequests.http +++ b/docs/rest/ConditionalDeleteRequests.http @@ -80,3 +80,44 @@ Authorization: Bearer {{bearer.response.body.access_token}} DELETE https://{{hostname}}/Patient?identifier=1032704&hardDelete=true&_count=100 content-type: application/json Authorization: Bearer {{bearer.response.body.access_token}} + +############################################################################### +### The Delete requests with transaction bundle + +### +# Delete multiple resources conditionally (should fail) +POST https://{{hostname}} +content-type: application/json +Authorization: Bearer {{bearer.response.body.access_token}} + +{ + "resourceType": "Bundle", + "type": "transaction", + "entry": [ + { + "request": { + "method": "DELETE", + "url": "Patient?identifier=1032704&_count=100" + } + } + ] +} + +### +# Hard Delete with id in transaction (should succeed) +POST https://{{hostname}} +content-type: application/json +Authorization: Bearer {{bearer.response.body.access_token}} + +{ + "resourceType": "Bundle", + "type": "transaction", + "entry": [ + { + "request": { + "method": "DELETE", + "url": "Patient/{{patient.response.body.id}}?_hardDelete=true" + } + } + ] +} \ No newline at end of file diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Features/Resources/Bundle/TransactionBundleValidator.cs b/src/Microsoft.Health.Fhir.Shared.Api/Features/Resources/Bundle/TransactionBundleValidator.cs index 8ffdd800b9..fa4b7470ff 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Features/Resources/Bundle/TransactionBundleValidator.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api/Features/Resources/Bundle/TransactionBundleValidator.cs @@ -83,6 +83,13 @@ private async Task GetResourceId(EntryComponent entry, IDictionary Date: Tue, 6 Jan 2026 14:58:50 -0600 Subject: [PATCH 2/2] Added unit tests to test different scenarios. --- .../Bundle/TransactionBundleValidatorTests.cs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Resources/Bundle/TransactionBundleValidatorTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Resources/Bundle/TransactionBundleValidatorTests.cs index 6ba5c20e8f..192e3ed4f6 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Resources/Bundle/TransactionBundleValidatorTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Resources/Bundle/TransactionBundleValidatorTests.cs @@ -107,6 +107,89 @@ public async Task GivenATransactionBundle_WhenContainsEntryWithConditionalDelete Assert.Equal(expectedMessage, exception.Message); } + [Fact] + public async Task GivenATransactionBundle_WhenContainsEntryWithHardDelete_ThenNoExceptionShouldBeThrown() + { + // Arrange + var bundle = new Hl7.Fhir.Model.Bundle + { + Type = Hl7.Fhir.Model.Bundle.BundleType.Transaction, + Entry = new List + { + new Hl7.Fhir.Model.Bundle.EntryComponent + { + Request = new Hl7.Fhir.Model.Bundle.RequestComponent + { + Method = Hl7.Fhir.Model.Bundle.HTTPVerb.DELETE, + Url = "Patient/123?_hardDelete=true", + }, + Resource = new Hl7.Fhir.Model.Patient { Id = "123" }, + }, + }, + }; + + // Act & Assert - Should not throw + await _transactionBundleValidator.ValidateBundle(bundle, _idDictionary, CancellationToken.None); + } + + [Theory] + [InlineData("Patient?identifier=123456", "Requested operation 'Patient?identifier=123456' is not supported using DELETE.")] + [InlineData("Patient?name=John", "Requested operation 'Patient?name=John' is not supported using DELETE.")] + [InlineData("Observation?code=12345", "Requested operation 'Observation?code=12345' is not supported using DELETE.")] + public async Task GivenATransactionBundle_WhenContainsConditionalDelete_ThenRequestNotValidExceptionShouldBeThrown(string requestUrl, string expectedMessage) + { + // Arrange + var bundle = new Hl7.Fhir.Model.Bundle + { + Type = Hl7.Fhir.Model.Bundle.BundleType.Transaction, + Entry = new List + { + new Hl7.Fhir.Model.Bundle.EntryComponent + { + Request = new Hl7.Fhir.Model.Bundle.RequestComponent + { + Method = Hl7.Fhir.Model.Bundle.HTTPVerb.DELETE, + Url = requestUrl, + }, + Resource = new Hl7.Fhir.Model.Patient(), + }, + }, + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _transactionBundleValidator.ValidateBundle(bundle, _idDictionary, CancellationToken.None)); + Assert.Equal(expectedMessage, exception.Message); + } + + [Theory] + [InlineData("Patient/123?_hardDelete=true")] + [InlineData("Observation/456?_purge=true")] + [InlineData("Patient/789?_hardDelete=true&_cascade=delete")] + public async Task GivenATransactionBundle_WhenContainsDeleteWithResourceIdAndQueryParams_ThenNoExceptionShouldBeThrown(string requestUrl) + { + // Arrange - These are hard deletes with resource IDs and query parameters + var bundle = new Hl7.Fhir.Model.Bundle + { + Type = Hl7.Fhir.Model.Bundle.BundleType.Transaction, + Entry = new List + { + new Hl7.Fhir.Model.Bundle.EntryComponent + { + Request = new Hl7.Fhir.Model.Bundle.RequestComponent + { + Method = Hl7.Fhir.Model.Bundle.HTTPVerb.DELETE, + Url = requestUrl, + }, + Resource = new Hl7.Fhir.Model.Patient(), + }, + }, + }; + + // Act & Assert - Should not throw + await _transactionBundleValidator.ValidateBundle(bundle, _idDictionary, CancellationToken.None); + } + [Theory] [InlineData("Patient?")] [InlineData("")]