From 239bf8d44148ea7c9bc615fa7f24d75a857f28af Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Mon, 14 Oct 2024 16:13:14 -0400 Subject: [PATCH] Add ChainedDelegateConsentService with pluggable vote strategy Introduce some plumbing utils to combine consent votes. --- .../7_6_0/6366-consent-plumbing.yaml | 4 + .../consent/ConsentOperationStatusEnum.java | 79 ++++----- .../interceptor/consent/ConsentOutcome.java | 27 ++- .../consent/ConstantConsentService.java | 48 +++++ .../interceptor/consent/IConsentVote.java | 71 ++++++++ .../consent/MultiDelegateConsentService.java | 74 ++++++++ .../ConsentOperationStatusEnumTest.java | 118 +------------ .../consent/ConstantConsentServiceTest.java | 70 ++++++++ .../interceptor/consent/IConsentVoteTest.java | 156 ++++++++++++++++ .../MultiDelegateConsentServiceTest.java | 166 ++++++++++++++++++ 10 files changed, 655 insertions(+), 158 deletions(-) create mode 100644 hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_6_0/6366-consent-plumbing.yaml create mode 100644 hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/ConstantConsentService.java create mode 100644 hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/IConsentVote.java create mode 100644 hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/MultiDelegateConsentService.java create mode 100644 hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/ConstantConsentServiceTest.java create mode 100644 hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/IConsentVoteTest.java create mode 100644 hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/MultiDelegateConsentServiceTest.java diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_6_0/6366-consent-plumbing.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_6_0/6366-consent-plumbing.yaml new file mode 100644 index 00000000000..3a3de3d1fd1 --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/7_6_0/6366-consent-plumbing.yaml @@ -0,0 +1,4 @@ +--- +type: add +issue: 6366 +title: "Add plumbing for combining IConsentServices with different vote tally strategies" diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/ConsentOperationStatusEnum.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/ConsentOperationStatusEnum.java index 81f70b61efe..1f60879d8d7 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/ConsentOperationStatusEnum.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/ConsentOperationStatusEnum.java @@ -21,7 +21,7 @@ package ca.uhn.fhir.rest.server.interceptor.consent; import java.util.stream.Stream; -public enum ConsentOperationStatusEnum { +public enum ConsentOperationStatusEnum implements IConsentVote { /** * The requested operation cannot proceed, and an operation outcome suitable for @@ -59,53 +59,52 @@ public enum ConsentOperationStatusEnum { return 0; } } + + /** + * Does this vote abstain from the verdict? + * I.e. this == PROCEED + * @return true if this vote can be ignored + */ + boolean isAbstain() { + return this == PROCEED; + } + + /** + * Does this vote participate from the verdict? + * I.e. this != PROCEED + * @return false if this vote can be ignored + */ + boolean isActiveVote() { + return this != PROCEED; + } + + @Override + public ConsentOperationStatusEnum getStatus() { + return this; + } + /** * Evaluate verdicts in order, taking the first "decision" (i.e. first non-PROCEED) verdict. * * @return the first decisive verdict, or PROCEED when empty or all PROCEED. */ + public static ConsentOperationStatusEnum serialReduce(Stream theVoteStream) { + return IConsentVote.serialReduce(PROCEED, theVoteStream); + } + + public static ConsentOperationStatusEnum parallelReduce(Stream theVoteStream) { + return IConsentVote.parallelReduce(PROCEED, theVoteStream); + } + + /** @deprecated for rename */ + @Deprecated(forRemoval = true) public static ConsentOperationStatusEnum serialEvaluate(Stream theVoteStream) { - return theVoteStream.filter(verdict -> PROCEED != verdict).findFirst().orElse(PROCEED); + return serialReduce(theVoteStream); } - /** - * Evaluate verdicts in order, taking the first "decision" (i.e. first non-PROCEED) verdict. - * - * @param theNextVerdict the next verdict to consider - * @return the combined verdict - */ - public ConsentOperationStatusEnum serialReduce(ConsentOperationStatusEnum theNextVerdict) { - if (this != PROCEED) { - return this; - } else { - return theNextVerdict; - } - } - - /** - * Evaluate all verdicts together, allowing any to veto (i.e. REJECT) the operation. - *
    - *
  • If any vote is REJECT, then the result is REJECT. - *
  • If no vote is REJECT, and any vote is AUTHORIZED, then the result is AUTHORIZED. - *
  • If no vote is REJECT or AUTHORIZED, the result is PROCEED. - *
- * - * @return REJECT if any reject, AUTHORIZED if no REJECT and some AUTHORIZED, PROCEED if empty or all PROCEED - */ + /** @deprecated for rename */ + @Deprecated(forRemoval = true) public static ConsentOperationStatusEnum parallelEvaluate(Stream theVoteStream) { - return theVoteStream.reduce(PROCEED, ConsentOperationStatusEnum::parallelReduce); - } - - /** - * Evaluate two verdicts together, allowing either to veto (i.e. REJECT) the operation. - * - * @return REJECT if either reject, AUTHORIZED if no REJECT and some AUTHORIZED, PROCEED otherwise - */ - public ConsentOperationStatusEnum parallelReduce(ConsentOperationStatusEnum theNextVerdict) { - if (theNextVerdict.getPrecedence() > this.getPrecedence()) { - return theNextVerdict; - } else { - return this; - } + return parallelReduce(theVoteStream); } } diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/ConsentOutcome.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/ConsentOutcome.java index 9bdc311fc56..8ea698a8c6c 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/ConsentOutcome.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/ConsentOutcome.java @@ -23,7 +23,9 @@ import org.apache.commons.lang3.Validate; import org.hl7.fhir.instance.model.api.IBaseOperationOutcome; import org.hl7.fhir.instance.model.api.IBaseResource; -public class ConsentOutcome { +import java.util.stream.Stream; + +public class ConsentOutcome implements IConsentVote { /** * Convenience constant containing new ConsentOutcome(ConsentOperationStatusEnum.PROCEED) @@ -67,6 +69,29 @@ public class ConsentOutcome { myResource = theResource; } + /** + * Evaluate all verdicts together, allowing any to veto (i.e. REJECT) the operation. + *
    + *
  • If any vote is REJECT, then the result is a REJECT vote. + *
  • If no vote is REJECT, and any vote is AUTHORIZED, then the result is one of the AUTHORIZED votes. + *
  • If no vote is REJECT or AUTHORIZED, the result is a PROCEED vote. + *
+ * + * @return REJECT if any reject, AUTHORIZED if no REJECT and some AUTHORIZED, PROCEED if empty or all PROCEED + */ + public static ConsentOutcome parallelReduce(Stream theOutcomes) { + return IConsentVote.parallelReduce(ConsentOutcome.PROCEED, theOutcomes); + } + + /** + * Evaluate verdicts in order, taking the first "decision" (i.e. first non-PROCEED) verdict. + * + * @return the first decisive verdict, or theSeed when empty or all PROCEED. + */ + public static ConsentOutcome serialReduce(Stream theStream) { + return IConsentVote.serialReduce(ConsentOutcome.PROCEED, theStream); + } + public ConsentOperationStatusEnum getStatus() { return myStatus; } diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/ConstantConsentService.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/ConstantConsentService.java new file mode 100644 index 00000000000..19a2849274c --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/ConstantConsentService.java @@ -0,0 +1,48 @@ +package ca.uhn.fhir.rest.server.interceptor.consent; + +import ca.uhn.fhir.rest.api.server.RequestDetails; +import jakarta.annotation.Nonnull; +import org.hl7.fhir.instance.model.api.IBaseResource; + +/** + * Consent Service that returns a fixed verdict. + */ +public class ConstantConsentService implements IConsentService { + @Nonnull + final ConsentOutcome myResult; + + public static ConstantConsentService constantService(ConsentOperationStatusEnum theResult) { + return new ConstantConsentService(new ConsentOutcome(theResult)); + } + + public ConstantConsentService(@Nonnull ConsentOutcome theResult) { + myResult = theResult; + } + + private @Nonnull ConsentOutcome getOutcome() { + return myResult; + } + + @Override + public ConsentOutcome startOperation(RequestDetails theRequestDetails, IConsentContextServices theContextServices) { + return getOutcome(); + } + + @Override + public boolean shouldProcessCanSeeResource( + RequestDetails theRequestDetails, IConsentContextServices theContextServices) { + return myResult.getStatus().isActiveVote(); + } + + @Override + public ConsentOutcome canSeeResource( + RequestDetails theRequestDetails, IBaseResource theResource, IConsentContextServices theContextServices) { + return getOutcome(); + } + + @Override + public ConsentOutcome willSeeResource( + RequestDetails theRequestDetails, IBaseResource theResource, IConsentContextServices theContextServices) { + return getOutcome(); + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/IConsentVote.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/IConsentVote.java new file mode 100644 index 00000000000..31ae4085cd0 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/IConsentVote.java @@ -0,0 +1,71 @@ +package ca.uhn.fhir.rest.server.interceptor.consent; + +import java.util.stream.Stream; + +/** + * Something that produces a vote, along with static + * tools for combining votes. + */ +public interface IConsentVote { + /** + * Get the vote + * @return the vote + */ + ConsentOperationStatusEnum getStatus(); + + /** + * Evaluate all verdicts together, allowing any to veto (i.e. REJECT) the operation. + *
    + *
  • If any vote is REJECT, then the result is REJECT. + *
  • If no vote is REJECT, and any vote is AUTHORIZED, then the result is AUTHORIZED. + *
  • If no vote is REJECT or AUTHORIZED, the result is PROCEED. + *
+ * + * @return REJECT if any reject, AUTHORIZED if no REJECT and some AUTHORIZED, PROCEED if empty or all PROCEED + */ + static T parallelReduce(T theSeed, Stream theVoteStream) { + return theVoteStream.reduce(theSeed, IConsentVote::parallelReduce); + } + + /** + * Evaluate two votes together, allowing either to veto (i.e. REJECT) the operation. + * + * @return REJECT if either reject, AUTHORIZED if no REJECT and some AUTHORIZED, PROCEED otherwise + */ + static T parallelReduce(T theAccumulator, T theNextVoter) { + if (theNextVoter.getStatus().getPrecedence() + < theAccumulator.getStatus().getPrecedence()) { + return theAccumulator; + } else { + return theNextVoter; + } + } + + /** + * Evaluate verdicts in order, taking the first "decision" (i.e. first non-PROCEED) verdict. + * + * @return the first decisive verdict, or theSeed when empty or all PROCEED. + */ + static T serialReduce(T theSeed, Stream theVoterStream) { + return theVoterStream.filter(IConsentVote::isActiveVote).findFirst().orElse(theSeed); + } + + /** + * Evaluate verdicts in order, taking the first "decision" (i.e. first non-PROCEED) verdict. + * + * @param theAccumulator the verdict so fat + * @param theNextVoter the next verdict to consider + * @return the combined verdict + */ + static T serialReduce(T theAccumulator, T theNextVoter) { + if (theAccumulator.getStatus().isAbstain()) { + return theNextVoter; + } else { + return theAccumulator; + } + } + + private static boolean isActiveVote(T nextVoter) { + return nextVoter.getStatus().isActiveVote(); + } +} diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/MultiDelegateConsentService.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/MultiDelegateConsentService.java new file mode 100644 index 00000000000..2a415ea2a55 --- /dev/null +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/interceptor/consent/MultiDelegateConsentService.java @@ -0,0 +1,74 @@ +package ca.uhn.fhir.rest.server.interceptor.consent; + +import ca.uhn.fhir.rest.api.server.RequestDetails; +import jakarta.annotation.Nonnull; +import org.hl7.fhir.instance.model.api.IBaseResource; + +import java.util.Collection; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; + +/** + * IConsentService combiner over several delegates with pluggable combination strategy + */ +public class MultiDelegateConsentService implements IConsentService { + private final Collection myDelegates; + private final Function, ConsentOutcome> myVoteCombiner; + + /** + * Combine several consent services allowing any to veto. + */ + public static @Nonnull MultiDelegateConsentService withParallelVoting( + @Nonnull List theDelegateConsentServices) { + return new MultiDelegateConsentService(ConsentOutcome::parallelReduce, theDelegateConsentServices); + } + + /** + * Combine several consent services with first non-PROCEED vote win. + */ + public static @Nonnull MultiDelegateConsentService withSerialVoting( + @Nonnull List theDelegateConsentServices) { + return new MultiDelegateConsentService(ConsentOutcome::serialReduce, theDelegateConsentServices); + } + + private MultiDelegateConsentService( + Function, ConsentOutcome> theVoteCombiner, + Collection theDelegates) { + myVoteCombiner = theVoteCombiner; + myDelegates = theDelegates; + } + + @Override + public ConsentOutcome startOperation(RequestDetails theRequestDetails, IConsentContextServices theContextServices) { + return myVoteCombiner.apply(myDelegates.stream() + .map(nextDelegate -> nextDelegate.startOperation(theRequestDetails, theContextServices))); + } + + /** + * @return true if any of the delegates return true. + */ + @Override + public boolean shouldProcessCanSeeResource( + RequestDetails theRequestDetails, IConsentContextServices theContextServices) { + return myDelegates.stream() + .map(nextDelegate -> nextDelegate.shouldProcessCanSeeResource(theRequestDetails, theContextServices)) + .filter(nextShould -> nextShould) + .findFirst() + .orElse(Boolean.FALSE); + } + + @Override + public ConsentOutcome canSeeResource( + RequestDetails theRequestDetails, IBaseResource theResource, IConsentContextServices theContextServices) { + return myVoteCombiner.apply(myDelegates.stream() + .map(nextDelegate -> nextDelegate.canSeeResource(theRequestDetails, theResource, theContextServices))); + } + + @Override + public ConsentOutcome willSeeResource( + RequestDetails theRequestDetails, IBaseResource theResource, IConsentContextServices theContextServices) { + return myVoteCombiner.apply(myDelegates.stream() + .map(nextDelegate -> nextDelegate.willSeeResource(theRequestDetails, theResource, theContextServices))); + } +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/ConsentOperationStatusEnumTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/ConsentOperationStatusEnumTest.java index ec046b6bba5..0e6674bdba1 100644 --- a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/ConsentOperationStatusEnumTest.java +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/ConsentOperationStatusEnumTest.java @@ -1,130 +1,14 @@ package ca.uhn.fhir.rest.server.interceptor.consent; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; - -import java.util.Arrays; -import java.util.stream.Stream; import static ca.uhn.fhir.rest.server.interceptor.consent.ConsentOperationStatusEnum.AUTHORIZED; import static ca.uhn.fhir.rest.server.interceptor.consent.ConsentOperationStatusEnum.PROCEED; import static ca.uhn.fhir.rest.server.interceptor.consent.ConsentOperationStatusEnum.REJECT; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertTrue; class ConsentOperationStatusEnumTest { - /** - * With "serial" evaluation, the first non-PROCEED verdict wins. - */ - @ParameterizedTest - @CsvSource(textBlock = """ - REJECT REJECT REJECT , REJECT - REJECT REJECT PROCEED , REJECT - REJECT REJECT AUTHORIZED, REJECT - REJECT PROCEED REJECT , REJECT - REJECT PROCEED PROCEED , REJECT - REJECT PROCEED AUTHORIZED, REJECT - REJECT AUTHORIZED REJECT , REJECT - REJECT AUTHORIZED PROCEED , REJECT - REJECT AUTHORIZED AUTHORIZED, REJECT - PROCEED REJECT REJECT , REJECT - PROCEED REJECT PROCEED , REJECT - PROCEED REJECT AUTHORIZED, REJECT - PROCEED PROCEED REJECT , REJECT - PROCEED PROCEED PROCEED , PROCEED - PROCEED PROCEED AUTHORIZED, AUTHORIZED - PROCEED AUTHORIZED REJECT , AUTHORIZED - PROCEED AUTHORIZED PROCEED , AUTHORIZED - PROCEED AUTHORIZED AUTHORIZED, AUTHORIZED - AUTHORIZED REJECT REJECT , AUTHORIZED - AUTHORIZED REJECT PROCEED , AUTHORIZED - AUTHORIZED REJECT AUTHORIZED, AUTHORIZED - AUTHORIZED PROCEED REJECT , AUTHORIZED - AUTHORIZED PROCEED PROCEED , AUTHORIZED - AUTHORIZED PROCEED AUTHORIZED, AUTHORIZED - AUTHORIZED AUTHORIZED REJECT , AUTHORIZED - AUTHORIZED AUTHORIZED PROCEED , AUTHORIZED - AUTHORIZED AUTHORIZED AUTHORIZED, AUTHORIZED - """) - void testSerialEvaluation_choosesFirstVerdict(String theInput, ConsentOperationStatusEnum theExpectedResult) { - // given - Stream consentOperationStatusEnumStream = Arrays.stream(theInput.split(" +")) - .map(String::trim) - .map(ConsentOperationStatusEnum::valueOf); - - // when - ConsentOperationStatusEnum result = ConsentOperationStatusEnum.serialEvaluate(consentOperationStatusEnumStream); - - assertEquals(theExpectedResult, result); - } - - @ParameterizedTest - @CsvSource(textBlock = """ - REJECT , REJECT , REJECT - REJECT , PROCEED , REJECT - REJECT , AUTHORIZED, REJECT - AUTHORIZED, REJECT , AUTHORIZED - AUTHORIZED, PROCEED , AUTHORIZED - AUTHORIZED, AUTHORIZED, AUTHORIZED - PROCEED , REJECT , REJECT - PROCEED , PROCEED , PROCEED - PROCEED , AUTHORIZED, AUTHORIZED - """) - void testSerialReduction_choosesFirstVerdict(ConsentOperationStatusEnum theFirst, ConsentOperationStatusEnum theSecond, ConsentOperationStatusEnum theExpectedResult) { - - // when - ConsentOperationStatusEnum result = theFirst.serialReduce(theSecond); - - assertEquals(theExpectedResult, result); - } - - - /** - * With "parallel" evaluation, the "strongest" verdict wins. - * REJECT > AUTHORIZED > PROCEED. - */ - @ParameterizedTest - @CsvSource(textBlock = """ - REJECT REJECT REJECT , REJECT - REJECT REJECT PROCEED , REJECT - REJECT REJECT AUTHORIZED, REJECT - REJECT PROCEED REJECT , REJECT - REJECT PROCEED PROCEED , REJECT - REJECT PROCEED AUTHORIZED, REJECT - REJECT AUTHORIZED REJECT , REJECT - REJECT AUTHORIZED PROCEED , REJECT - REJECT AUTHORIZED AUTHORIZED, REJECT - PROCEED REJECT REJECT , REJECT - PROCEED REJECT PROCEED , REJECT - PROCEED REJECT AUTHORIZED, REJECT - PROCEED PROCEED REJECT , REJECT - PROCEED PROCEED PROCEED , PROCEED - PROCEED PROCEED AUTHORIZED, AUTHORIZED - PROCEED AUTHORIZED REJECT , REJECT - PROCEED AUTHORIZED PROCEED , AUTHORIZED - PROCEED AUTHORIZED AUTHORIZED, AUTHORIZED - AUTHORIZED REJECT REJECT , REJECT - AUTHORIZED REJECT PROCEED , REJECT - AUTHORIZED REJECT AUTHORIZED, REJECT - AUTHORIZED PROCEED REJECT , REJECT - AUTHORIZED PROCEED PROCEED , AUTHORIZED - AUTHORIZED PROCEED AUTHORIZED, AUTHORIZED - AUTHORIZED AUTHORIZED REJECT , REJECT - AUTHORIZED AUTHORIZED PROCEED , AUTHORIZED - AUTHORIZED AUTHORIZED AUTHORIZED, AUTHORIZED - """) - void testParallelReduction_strongestVerdictWins(String theInput, ConsentOperationStatusEnum theExpectedResult) { - // given - Stream consentOperationStatusEnumStream = Arrays.stream(theInput.split(" +")) - .map(String::trim) - .map(ConsentOperationStatusEnum::valueOf); - - // when - ConsentOperationStatusEnum result = ConsentOperationStatusEnum.parallelEvaluate(consentOperationStatusEnumStream); - - assertEquals(theExpectedResult, result); - } @Test void testStrengthOrder() { diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/ConstantConsentServiceTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/ConstantConsentServiceTest.java new file mode 100644 index 00000000000..dabe3a47705 --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/ConstantConsentServiceTest.java @@ -0,0 +1,70 @@ +package ca.uhn.fhir.rest.server.interceptor.consent; + +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static ca.uhn.fhir.rest.server.interceptor.consent.IConsentContextServices.NULL_IMPL; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ConstantConsentServiceTest { + SystemRequestDetails mySrd = new SystemRequestDetails(); + + @ParameterizedTest + @EnumSource(ConsentOperationStatusEnum.class) + void testStartOperation(ConsentOperationStatusEnum theStatus) { + // given + var svc = ConstantConsentService.constantService(theStatus); + + // when + var outcome = svc.startOperation(mySrd,NULL_IMPL); + + // then + assertEquals(outcome.getStatus(), theStatus); + } + + + /** + * There's no point calling canSee if we return PROCEED. + */ + @ParameterizedTest + @EnumSource(ConsentOperationStatusEnum.class) + void testShouldProcessCanSeeResource(ConsentOperationStatusEnum theStatus) { + // given + var svc = ConstantConsentService.constantService(theStatus); + + // when + boolean outcome = svc.shouldProcessCanSeeResource(mySrd,NULL_IMPL); + + // then there's no point calling canSee if we return PROCEED. + boolean isNotAbstain = theStatus != ConsentOperationStatusEnum.PROCEED; + assertEquals(outcome, isNotAbstain); + } + + @ParameterizedTest + @EnumSource(ConsentOperationStatusEnum.class) + void testCanSeeResource(ConsentOperationStatusEnum theStatus) { + // given + var svc = ConstantConsentService.constantService(theStatus); + + // when + var outcome = svc.canSeeResource(mySrd, null, NULL_IMPL); + + // then + assertEquals(outcome.getStatus(), theStatus); + } + + + @ParameterizedTest + @EnumSource(ConsentOperationStatusEnum.class) + void testWillSeeResource(ConsentOperationStatusEnum theStatus) { + // given + var svc = ConstantConsentService.constantService(theStatus); + + // when + var outcome = svc.willSeeResource(mySrd, null, NULL_IMPL); + + // then + assertEquals(outcome.getStatus(), theStatus); + } +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/IConsentVoteTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/IConsentVoteTest.java new file mode 100644 index 00000000000..42546914ba4 --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/IConsentVoteTest.java @@ -0,0 +1,156 @@ +package ca.uhn.fhir.rest.server.interceptor.consent; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import jakarta.annotation.Nonnull; +import java.util.Arrays; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class IConsentVoteTest { + + /** col1: stream of votes, col2: expected verdict */ + public static final String SERIAL_STREAM_EXPECTATION = """ + REJECT REJECT REJECT , REJECT + REJECT REJECT PROCEED , REJECT + REJECT REJECT AUTHORIZED, REJECT + REJECT PROCEED REJECT , REJECT + REJECT PROCEED PROCEED , REJECT + REJECT PROCEED AUTHORIZED, REJECT + REJECT AUTHORIZED REJECT , REJECT + REJECT AUTHORIZED PROCEED , REJECT + REJECT AUTHORIZED AUTHORIZED, REJECT + PROCEED REJECT REJECT , REJECT + PROCEED REJECT PROCEED , REJECT + PROCEED REJECT AUTHORIZED, REJECT + PROCEED PROCEED REJECT , REJECT + PROCEED PROCEED PROCEED , PROCEED + PROCEED PROCEED AUTHORIZED, AUTHORIZED + PROCEED AUTHORIZED REJECT , AUTHORIZED + PROCEED AUTHORIZED PROCEED , AUTHORIZED + PROCEED AUTHORIZED AUTHORIZED, AUTHORIZED + AUTHORIZED REJECT REJECT , AUTHORIZED + AUTHORIZED REJECT PROCEED , AUTHORIZED + AUTHORIZED REJECT AUTHORIZED, AUTHORIZED + AUTHORIZED PROCEED REJECT , AUTHORIZED + AUTHORIZED PROCEED PROCEED , AUTHORIZED + AUTHORIZED PROCEED AUTHORIZED, AUTHORIZED + AUTHORIZED AUTHORIZED REJECT , AUTHORIZED + AUTHORIZED AUTHORIZED PROCEED , AUTHORIZED + AUTHORIZED AUTHORIZED AUTHORIZED, AUTHORIZED + """; + + /** col1: stream of votes, col2: expected verdict */ + public static final String PARALLEL_STREAM_EXPECTATION = """ + REJECT REJECT REJECT , REJECT + REJECT REJECT PROCEED , REJECT + REJECT REJECT AUTHORIZED, REJECT + REJECT PROCEED REJECT , REJECT + REJECT PROCEED PROCEED , REJECT + REJECT PROCEED AUTHORIZED, REJECT + REJECT AUTHORIZED REJECT , REJECT + REJECT AUTHORIZED PROCEED , REJECT + REJECT AUTHORIZED AUTHORIZED, REJECT + PROCEED REJECT REJECT , REJECT + PROCEED REJECT PROCEED , REJECT + PROCEED REJECT AUTHORIZED, REJECT + PROCEED PROCEED REJECT , REJECT + PROCEED PROCEED PROCEED , PROCEED + PROCEED PROCEED AUTHORIZED, AUTHORIZED + PROCEED AUTHORIZED REJECT , REJECT + PROCEED AUTHORIZED PROCEED , AUTHORIZED + PROCEED AUTHORIZED AUTHORIZED, AUTHORIZED + AUTHORIZED REJECT REJECT , REJECT + AUTHORIZED REJECT PROCEED , REJECT + AUTHORIZED REJECT AUTHORIZED, REJECT + AUTHORIZED PROCEED REJECT , REJECT + AUTHORIZED PROCEED PROCEED , AUTHORIZED + AUTHORIZED PROCEED AUTHORIZED, AUTHORIZED + AUTHORIZED AUTHORIZED REJECT , REJECT + AUTHORIZED AUTHORIZED PROCEED , AUTHORIZED + AUTHORIZED AUTHORIZED AUTHORIZED, AUTHORIZED + """; + + @ParameterizedTest + @CsvSource(textBlock = """ + REJECT , REJECT , REJECT + REJECT , PROCEED , REJECT + REJECT , AUTHORIZED, REJECT + AUTHORIZED, REJECT , AUTHORIZED + AUTHORIZED, PROCEED , AUTHORIZED + AUTHORIZED, AUTHORIZED, AUTHORIZED + PROCEED , REJECT , REJECT + PROCEED , PROCEED , PROCEED + PROCEED , AUTHORIZED, AUTHORIZED + """) + void testSerialReduction_choosesFirstVerdict(ConsentOperationStatusEnum theFirst, ConsentOperationStatusEnum theSecond, ConsentOperationStatusEnum theExpectedResult) { + + // when + ConsentOperationStatusEnum result = IConsentVote.serialReduce(theFirst, theSecond); + + assertEquals(theExpectedResult, result); + } + + + /** + * With "serial" evaluation, the first non-PROCEED verdict wins. + */ + @ParameterizedTest + @CsvSource(textBlock = SERIAL_STREAM_EXPECTATION) + void testSerialStreamReduction_choosesFirstVerdict(String theInput, ConsentOperationStatusEnum theExpectedResult) { + // given + Stream consentOperationStatusEnumStream = splitEnumsToStream(theInput); + + // when + ConsentOperationStatusEnum result = ConsentOperationStatusEnum.serialReduce(consentOperationStatusEnumStream); + + assertEquals(theExpectedResult, result); + } + + static @Nonnull Stream splitEnumsToStream(String theInput) { + return Arrays.stream(theInput.split(" +")) + .map(String::trim) + .map(ConsentOperationStatusEnum::valueOf); + } + + + @ParameterizedTest + @CsvSource(textBlock = """ + REJECT , REJECT , REJECT + REJECT , PROCEED , REJECT + REJECT , AUTHORIZED, REJECT + AUTHORIZED, REJECT , REJECT + AUTHORIZED, PROCEED , AUTHORIZED + AUTHORIZED, AUTHORIZED, AUTHORIZED + PROCEED , REJECT , REJECT + PROCEED , PROCEED , PROCEED + PROCEED , AUTHORIZED, AUTHORIZED + """) + void testParallelReduction_choosesStrongestVerdict(ConsentOperationStatusEnum theFirst, ConsentOperationStatusEnum theSecond, ConsentOperationStatusEnum theExpectedResult) { + + // when + ConsentOperationStatusEnum result = IConsentVote.parallelReduce(theFirst, theSecond); + + assertEquals(theExpectedResult, result); + } + + + /** + * With "parallel" evaluation, the "strongest" verdict wins. + * REJECT > AUTHORIZED > PROCEED. + */ + @ParameterizedTest + @CsvSource(textBlock = PARALLEL_STREAM_EXPECTATION) + void testParallelStreamReduction_strongestVerdictWins(String theInput, ConsentOperationStatusEnum theExpectedResult) { + // given + Stream consentOperationStatusEnumStream = splitEnumsToStream(theInput); + + // when + ConsentOperationStatusEnum result = ConsentOperationStatusEnum.parallelReduce(consentOperationStatusEnumStream); + + assertEquals(theExpectedResult, result); + } + +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/MultiDelegateConsentServiceTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/MultiDelegateConsentServiceTest.java new file mode 100644 index 00000000000..b495b0e0c69 --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/interceptor/consent/MultiDelegateConsentServiceTest.java @@ -0,0 +1,166 @@ +package ca.uhn.fhir.rest.server.interceptor.consent; + +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import jakarta.annotation.Nonnull; +import java.util.Arrays; +import java.util.List; + +import static ca.uhn.fhir.rest.server.interceptor.consent.MultiDelegateConsentService.withParallelVoting; +import static ca.uhn.fhir.rest.server.interceptor.consent.MultiDelegateConsentService.withSerialVoting; +import static ca.uhn.fhir.rest.server.interceptor.consent.IConsentVoteTest.PARALLEL_STREAM_EXPECTATION; +import static ca.uhn.fhir.rest.server.interceptor.consent.IConsentVoteTest.SERIAL_STREAM_EXPECTATION; +import static ca.uhn.fhir.rest.server.interceptor.consent.IConsentVoteTest.splitEnumsToStream; +import static org.apache.commons.lang3.StringUtils.defaultString; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MultiDelegateConsentServiceTest { + SystemRequestDetails mySrd = new SystemRequestDetails(); + + /** + * "parallel" means any voter can veto. + */ + @Nested + class ParallelEvaluation { + MultiDelegateConsentService myService; + + @ParameterizedTest + @CsvSource(textBlock = PARALLEL_STREAM_EXPECTATION) + void testStartOperation(String theInput, ConsentOperationStatusEnum theExpectedResult) { + + var services = splitEnumsToStream(theInput).map(result -> (IConsentService)ConstantConsentService.constantService(result)).toList(); + myService = withParallelVoting(services); + + var verdict = myService.startOperation(mySrd, IConsentContextServices.NULL_IMPL); + + assertEquals(theExpectedResult.getStatus(), verdict.getStatus()); + } + + + @ParameterizedTest + @CsvSource(textBlock = """ + , false + true , true + false , false + false true , true + true false , true + """) + void testCanSeeResource(String theInput, boolean theExpectedResult) { + + List consentServices = Arrays.stream(defaultString(theInput).split(" +")) + .map(String::trim) + .map(Boolean::valueOf) + .map(MultiDelegateConsentServiceTest::buildConsentShouldProcessCanSee) + .toList(); + myService = withParallelVoting(consentServices); + + var result = myService.shouldProcessCanSeeResource(mySrd, IConsentContextServices.NULL_IMPL); + + assertEquals(theExpectedResult, result); + } + + @ParameterizedTest + @CsvSource(textBlock = PARALLEL_STREAM_EXPECTATION) + void testCanSeeResource(String theInput, ConsentOperationStatusEnum theExpectedResult) { + + var services = splitEnumsToStream(theInput).map(result -> (IConsentService)ConstantConsentService.constantService(result)).toList(); + myService = withParallelVoting(services); + + var verdict = myService.canSeeResource(mySrd, null, IConsentContextServices.NULL_IMPL); + + assertEquals(theExpectedResult.getStatus(), verdict.getStatus()); + } + + @ParameterizedTest + @CsvSource(textBlock = PARALLEL_STREAM_EXPECTATION) + void testWillSeeResource(String theInput, ConsentOperationStatusEnum theExpectedResult) { + + var services = splitEnumsToStream(theInput).map(result -> (IConsentService)ConstantConsentService.constantService(result)).toList(); + myService = withParallelVoting(services); + + var verdict = myService.willSeeResource(mySrd, null, IConsentContextServices.NULL_IMPL); + + assertEquals(theExpectedResult.getStatus(), verdict.getStatus()); + } + } + + /** + * "serial" means first comited vote wins + */ + @Nested + class SerialEvaluation { + MultiDelegateConsentService myService; + + @ParameterizedTest + @CsvSource(textBlock = SERIAL_STREAM_EXPECTATION) + void testStartOperation(String theInput, ConsentOperationStatusEnum theExpectedResult) { + + var services = splitEnumsToStream(theInput).map(result -> (IConsentService)ConstantConsentService.constantService(result)).toList(); + myService = withSerialVoting(services); + + var verdict = myService.startOperation(mySrd, IConsentContextServices.NULL_IMPL); + + assertEquals(theExpectedResult.getStatus(), verdict.getStatus()); + } + + @ParameterizedTest + @CsvSource(textBlock = """ + , false + true , true + false , false + false true , true + true false , true + """) + void testCanSeeResource(String theInput, boolean theExpectedResult) { + + List consentServices = Arrays.stream(defaultString(theInput).split(" +")) + .map(String::trim) + .map(Boolean::valueOf) + .map(MultiDelegateConsentServiceTest::buildConsentShouldProcessCanSee) + .toList(); + myService = withSerialVoting(consentServices); + + var result = myService.shouldProcessCanSeeResource(mySrd, IConsentContextServices.NULL_IMPL); + + assertEquals(theExpectedResult, result); + } + + @ParameterizedTest + @CsvSource(textBlock = SERIAL_STREAM_EXPECTATION) + void testCanSeeResource(String theInput, ConsentOperationStatusEnum theExpectedResult) { + + var services = splitEnumsToStream(theInput).map(result -> (IConsentService)ConstantConsentService.constantService(result)).toList(); + myService = withSerialVoting(services); + + var verdict = myService.canSeeResource(mySrd, null, IConsentContextServices.NULL_IMPL); + + assertEquals(theExpectedResult.getStatus(), verdict.getStatus()); + } + + @ParameterizedTest + @CsvSource(textBlock = SERIAL_STREAM_EXPECTATION) + void testWillSeeResource(String theInput, ConsentOperationStatusEnum theExpectedResult) { + + var services = splitEnumsToStream(theInput).map(result -> (IConsentService)ConstantConsentService.constantService(result)).toList(); + myService = withSerialVoting(services); + + var verdict = myService.willSeeResource(mySrd, null, IConsentContextServices.NULL_IMPL); + + assertEquals(theExpectedResult.getStatus(), verdict.getStatus()); + } + } + + private static @Nonnull IConsentService buildConsentShouldProcessCanSee(boolean result) { + return new IConsentService() { + @Override + public boolean shouldProcessCanSeeResource(RequestDetails theRequestDetails, IConsentContextServices theContextServices) { + return result; + } + }; + } + +}