From 12765ac397030493929cbeec6d1ddd2d0472d642 Mon Sep 17 00:00:00 2001 From: jdar8 <69840459+jdar8@users.noreply.github.com> Date: Fri, 6 Sep 2024 10:16:49 -0700 Subject: [PATCH] Disabling enforce referential integrity on write still checks references (#6219) * fix * tests, changelog, spotless * document tests better * address review comments * reset settings so other tests don't fail * unused import --------- Co-authored-by: jdar <justin.dar@smiledigitalhealth.com> --- ...y-on-write-disabled-still-checks-refs.yaml | 6 + ...ResourceDaoR4ReferentialIntegrityTest.java | 168 +++++++++++++++--- .../dao/index/DaoResourceLinkResolver.java | 57 ++++-- 3 files changed, 194 insertions(+), 37 deletions(-) create mode 100644 hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_6_0/6218-fix-ref-integrity-on-write-disabled-still-checks-refs.yaml diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_6_0/6218-fix-ref-integrity-on-write-disabled-still-checks-refs.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_6_0/6218-fix-ref-integrity-on-write-disabled-still-checks-refs.yaml new file mode 100644 index 00000000000..b6469d5c8b5 --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_6_0/6218-fix-ref-integrity-on-write-disabled-still-checks-refs.yaml @@ -0,0 +1,6 @@ +--- +type: fix +issue: 6218 +jira: SMILE-8370 +title: "Previously, when disabling the 'Enforce Referential Integrity on Write' setting, referential integrity was still +partially enforced when posting a resource with an invalid reference. This has now been fixed." diff --git a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4ReferentialIntegrityTest.java b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4ReferentialIntegrityTest.java index f75fce4b44c..8020f0556ea 100644 --- a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4ReferentialIntegrityTest.java +++ b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/dao/r4/FhirResourceDaoR4ReferentialIntegrityTest.java @@ -1,19 +1,26 @@ package ca.uhn.fhir.jpa.dao.r4; -import static org.junit.jupiter.api.Assertions.assertEquals; import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; import ca.uhn.fhir.jpa.test.BaseJpaR4Test; import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; import ca.uhn.fhir.rest.server.exceptions.ResourceVersionConflictException; +import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4.model.IdType; +import org.hl7.fhir.r4.model.Observation; import org.hl7.fhir.r4.model.Organization; import org.hl7.fhir.r4.model.Patient; import org.hl7.fhir.r4.model.Reference; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; -import static org.assertj.core.api.Assertions.assertThat; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.fail; public class FhirResourceDaoR4ReferentialIntegrityTest extends BaseJpaR4Test { @@ -23,51 +30,168 @@ public class FhirResourceDaoR4ReferentialIntegrityTest extends BaseJpaR4Test { myStorageSettings.setEnforceReferentialIntegrityOnWrite(new JpaStorageSettings().isEnforceReferentialIntegrityOnWrite()); myStorageSettings.setEnforceReferentialIntegrityOnDelete(new JpaStorageSettings().isEnforceReferentialIntegrityOnDelete()); myStorageSettings.setEnforceReferenceTargetTypes(new JpaStorageSettings().isEnforceReferenceTargetTypes()); + myStorageSettings.setResourceClientIdStrategy(new JpaStorageSettings().getResourceClientIdStrategy()); + } + + @ParameterizedTest + @MethodSource("paramsProvider_withResourceType") + public void referentialIntegrityOnWriteSetting_unknownIds_fullScopeTest(boolean theIsEnforceRefIntegrityEnabled, + JpaStorageSettings.ClientIdStrategyEnum theClientIdStrategy, + String theReferenceId) { + // Given + myStorageSettings.setEnforceReferentialIntegrityOnWrite(theIsEnforceRefIntegrityEnabled); + myStorageSettings.setEnforceReferenceTargetTypes(theIsEnforceRefIntegrityEnabled); + myStorageSettings.setResourceClientIdStrategy(theClientIdStrategy); + + Patient p = new Patient(); + p.setManagingOrganization(new Reference(theReferenceId)); + + if (theIsEnforceRefIntegrityEnabled) { + try { + // When + myPatientDao.create(p); + fail(); + } catch (InvalidRequestException e) { + // Then + assertEquals(Msg.code(1094) + "Resource " + theReferenceId + " not found, specified in path: Patient.managingOrganization", e.getMessage()); + } + } else { + // Disabled ref integrity on write case: all POSTs should succeed + // When + IIdType id = myPatientDao.create(p).getId().toUnqualifiedVersionless(); + // Then + p = myPatientDao.read(id); + assertEquals(theReferenceId, p.getManagingOrganization().getReference()); + } + } + + private static Stream<Arguments> paramsProvider_withResourceType() { + // theIsEnforceRefIntegrityEnabled, theClientIdStrategy, theReferenceId + // note: client ID is tested since resolving the resource reference is different depending on the strategy + return Stream.of( + Arguments.of(true, JpaStorageSettings.ClientIdStrategyEnum.ALPHANUMERIC, "Organization/123"), + Arguments.of(true, JpaStorageSettings.ClientIdStrategyEnum.ALPHANUMERIC, "Organization/A123"), + Arguments.of(true, JpaStorageSettings.ClientIdStrategyEnum.ANY, "Organization/123"), + Arguments.of(true, JpaStorageSettings.ClientIdStrategyEnum.ANY, "Organization/A123"), + Arguments.of(false, JpaStorageSettings.ClientIdStrategyEnum.ALPHANUMERIC, "Organization/123"), + Arguments.of(false, JpaStorageSettings.ClientIdStrategyEnum.ALPHANUMERIC, "Organization/A123"), + Arguments.of(false, JpaStorageSettings.ClientIdStrategyEnum.ANY, "Organization/123"), + Arguments.of(false, JpaStorageSettings.ClientIdStrategyEnum.ANY, "Organization/A123") + ); } @Test - public void testCreateUnknownReferenceFail() throws Exception { + public void testRefIntegrityOnWrite_withReferenceIdOfAnotherResourceType() { + // Given + myStorageSettings.setResourceClientIdStrategy(JpaStorageSettings.ClientIdStrategyEnum.ALPHANUMERIC); + // Create Observation with some ID... + Observation obs = new Observation(); + IIdType obsId = myObservationDao.create(obs, mySrd).getId().toUnqualifiedVersionless(); + + // And reference a non-existing Organization resource with the same ID as the Observation Patient p = new Patient(); - p.setManagingOrganization(new Reference("Organization/AAA")); + p.setManagingOrganization(new Reference(new IdType("Organization", obsId.getIdPart()))); + try { + // When myPatientDao.create(p); fail(); - } catch (InvalidRequestException e) { - assertEquals(Msg.code(1094) + "Resource Organization/AAA not found, specified in path: Patient.managingOrganization", e.getMessage()); + } catch (UnprocessableEntityException e) { + // Then: identify that it is the wrong resource type, since ref integrity is enabled + assertEquals(Msg.code(1095) + "Resource contains reference to unknown resource ID Organization/" + obsId.getIdPart(), e.getMessage()); } + // Given: now disable referential integrity on write + myStorageSettings.setEnforceReferentialIntegrityOnWrite(false); + // When + IIdType id = myPatientDao.create(p).getId().toUnqualifiedVersionless(); + // Then: this should now succeed + p = myPatientDao.read(id); + assertEquals("Organization/" + obsId.getIdPart(), p.getManagingOrganization().getReference()); } - @Test - public void testCreateUnknownReferenceAllowed() { - myStorageSettings.setEnforceReferentialIntegrityOnWrite(false); + @ParameterizedTest + @MethodSource("paramsProvider_noResourceType") + public void testRefIntegrityOnWrite_withValidReferenceId_shouldAlwaysSucceed(boolean theIsEnforceRefIntegrityEnabled, + JpaStorageSettings.ClientIdStrategyEnum theClientIdStrategy, + String theReferenceId) { + // Given + myStorageSettings.setEnforceReferentialIntegrityOnWrite(theIsEnforceRefIntegrityEnabled); + myStorageSettings.setEnforceReferenceTargetTypes(theIsEnforceRefIntegrityEnabled); + myStorageSettings.setResourceClientIdStrategy(theClientIdStrategy); + + Organization obs = new Organization(); + obs.setId(theReferenceId); + myOrganizationDao.update(obs, mySrd); + String qualifiedReferenceId = "Organization/" + theReferenceId; Patient p = new Patient(); - p.setManagingOrganization(new Reference("Organization/AAA")); + p.setManagingOrganization(new Reference(qualifiedReferenceId)); + + // When IIdType id = myPatientDao.create(p).getId().toUnqualifiedVersionless(); + // Then p = myPatientDao.read(id); - assertEquals("Organization/AAA", p.getManagingOrganization().getReference()); - + assertEquals(qualifiedReferenceId, p.getManagingOrganization().getReference()); } - @Test - public void testCreateUnknownReferenceAllowed_NumericId() { - myStorageSettings.setEnforceReferentialIntegrityOnWrite(false); - myStorageSettings.setEnforceReferenceTargetTypes(false); + private static Stream<Arguments> paramsProvider_noResourceType() { + // theIsEnforceRefIntegrityEnabled, theClientIdStrategy, theReferenceId + // note: client ID is tested since resolving the resource reference is different depending on the strategy + return Stream.of( + Arguments.of(true, JpaStorageSettings.ClientIdStrategyEnum.ALPHANUMERIC, "A123"), + Arguments.of(true, JpaStorageSettings.ClientIdStrategyEnum.ANY, "123"), + Arguments.of(true, JpaStorageSettings.ClientIdStrategyEnum.ANY, "A123"), + Arguments.of(false, JpaStorageSettings.ClientIdStrategyEnum.ALPHANUMERIC, "A123"), + Arguments.of(false, JpaStorageSettings.ClientIdStrategyEnum.ANY, "123"), + Arguments.of(false, JpaStorageSettings.ClientIdStrategyEnum.ANY, "A123") + ); + } + + @ParameterizedTest + @MethodSource("paramsProvider_noResourceType") + public void testRefIntegrityOnWrite_withReferenceIdOfDeletedResource(boolean theIsEnforceRefIntegrityEnabled, + JpaStorageSettings.ClientIdStrategyEnum theClientIdStrategy, + String theReferenceId) { + // Given + myStorageSettings.setEnforceReferentialIntegrityOnWrite(theIsEnforceRefIntegrityEnabled); + myStorageSettings.setEnforceReferenceTargetTypes(theIsEnforceRefIntegrityEnabled); + myStorageSettings.setResourceClientIdStrategy(theClientIdStrategy); + + Organization obs = new Organization(); + obs.setId(theReferenceId); + IIdType obsId = myOrganizationDao.update(obs, mySrd).getId(); + String qualifiedReferenceId = "Organization/" + theReferenceId; + + myOrganizationDao.delete(obsId, mySrd); Patient p = new Patient(); - p.setManagingOrganization(new Reference("Organization/123")); - IIdType id = myPatientDao.create(p).getId().toUnqualifiedVersionless(); + p.setManagingOrganization(new Reference(qualifiedReferenceId)); - p = myPatientDao.read(id); - assertEquals("Organization/123", p.getManagingOrganization().getReference()); + if (theIsEnforceRefIntegrityEnabled) { + try { + // When + myPatientDao.create(p); + fail(); + } catch (InvalidRequestException e) { + // Then + assertEquals(Msg.code(1096) + "Resource " + qualifiedReferenceId + " is deleted, specified in path: Patient.managingOrganization", e.getMessage()); + } + } else { + // Disabled ref integrity on write case: all POSTs should succeed + // When + IIdType id = myPatientDao.create(p).getId().toUnqualifiedVersionless(); + // Then + p = myPatientDao.read(id); + assertEquals(qualifiedReferenceId, p.getManagingOrganization().getReference()); + } } @Test - public void testDeleteFail() throws Exception { + public void testDeleteFail() { Organization o = new Organization(); o.setName("FOO"); IIdType oid = myOrganizationDao.create(o).getId().toUnqualifiedVersionless(); @@ -89,7 +213,7 @@ public class FhirResourceDaoR4ReferentialIntegrityTest extends BaseJpaR4Test { } @Test - public void testDeleteAllow() throws Exception { + public void testDeleteAllow() { myStorageSettings.setEnforceReferentialIntegrityOnDelete(false); Organization o = new Organization(); diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/dao/index/DaoResourceLinkResolver.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/dao/index/DaoResourceLinkResolver.java index e76ed2801bb..9434e126936 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/dao/index/DaoResourceLinkResolver.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/dao/index/DaoResourceLinkResolver.java @@ -134,7 +134,7 @@ public class DaoResourceLinkResolver<T extends IResourcePersistentId> implements type, targetReference, idPart, theRequest, theTransactionDetails); if (!createdTableOpt.isPresent()) { - if (myStorageSettings.isEnforceReferentialIntegrityOnWrite() == false) { + if (!myStorageSettings.isEnforceReferentialIntegrityOnWrite()) { return null; } @@ -150,20 +150,8 @@ public class DaoResourceLinkResolver<T extends IResourcePersistentId> implements "Resolved resource of type {} as PID: {}", resolvedResource.getResourceType(), resolvedResource.getPersistentId()); - if (!resourceType.equals(resolvedResource.getResourceType())) { - ourLog.error( - "Resource with PID {} was of type {} and wanted {}", - resolvedResource.getPersistentId(), - resourceType, - resolvedResource.getResourceType()); - throw new UnprocessableEntityException(Msg.code(1095) - + "Resource contains reference to unknown resource ID " + targetResourceId.getValue()); - } - - if (resolvedResource.getDeleted() != null) { - String resName = resolvedResource.getResourceType(); - throw new InvalidRequestException(Msg.code(1096) + "Resource " + resName + "/" + idPart - + " is deleted, specified in path: " + sourcePath); + if (!validateResolvedResourceOrThrow(resourceType, resolvedResource, targetResourceId, idPart, sourcePath)) { + return null; } if (persistentId == null) { @@ -182,6 +170,45 @@ public class DaoResourceLinkResolver<T extends IResourcePersistentId> implements return resolvedResource; } + /** + * Validates the resolved resource. + * If 'Enforce Referential Integrity on Write' is enabled: + * Throws <code>UnprocessableEntityException</code> when resource types do not match + * Throws <code>InvalidRequestException</code> when the resolved resource was deleted + * <p> + * Otherwise, return false when resource types do not match or resource was deleted + * and return true if the resolved resource is valid. + */ + private boolean validateResolvedResourceOrThrow( + String resourceType, + IResourceLookup resolvedResource, + IIdType targetResourceId, + String idPart, + String sourcePath) { + if (!resourceType.equals(resolvedResource.getResourceType())) { + ourLog.error( + "Resource with PID {} was of type {} and wanted {}", + resolvedResource.getPersistentId(), + resourceType, + resolvedResource.getResourceType()); + if (!myStorageSettings.isEnforceReferentialIntegrityOnWrite()) { + return false; + } + throw new UnprocessableEntityException(Msg.code(1095) + + "Resource contains reference to unknown resource ID " + targetResourceId.getValue()); + } + + if (resolvedResource.getDeleted() != null) { + if (!myStorageSettings.isEnforceReferentialIntegrityOnWrite()) { + return false; + } + String resName = resolvedResource.getResourceType(); + throw new InvalidRequestException(Msg.code(1096) + "Resource " + resName + "/" + idPart + + " is deleted, specified in path: " + sourcePath); + } + return true; + } + @Nullable @Override public IBaseResource loadTargetResource(