Add ChainedDelegateConsentService with pluggable vote strategy

Introduce some plumbing utils to combine consent votes.
This commit is contained in:
Michael Buckley 2024-10-14 16:13:14 -04:00 committed by GitHub
parent 6a365f8722
commit 239bf8d441
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 655 additions and 158 deletions

View File

@ -0,0 +1,4 @@
---
type: add
issue: 6366
title: "Add plumbing for combining IConsentServices with different vote tally strategies"

View File

@ -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<ConsentOperationStatusEnum> theVoteStream) {
return IConsentVote.serialReduce(PROCEED, theVoteStream);
}
public static ConsentOperationStatusEnum parallelReduce(Stream<ConsentOperationStatusEnum> theVoteStream) {
return IConsentVote.parallelReduce(PROCEED, theVoteStream);
}
/** @deprecated for rename */
@Deprecated(forRemoval = true)
public static ConsentOperationStatusEnum serialEvaluate(Stream<ConsentOperationStatusEnum> 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.
* <ul>
* <li>If any vote is REJECT, then the result is REJECT.
* <li>If no vote is REJECT, and any vote is AUTHORIZED, then the result is AUTHORIZED.
* <li>If no vote is REJECT or AUTHORIZED, the result is PROCEED.
* </ul>
*
* @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<ConsentOperationStatusEnum> 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);
}
}

View File

@ -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 <code>new ConsentOutcome(ConsentOperationStatusEnum.PROCEED)</code>
@ -67,6 +69,29 @@ public class ConsentOutcome {
myResource = theResource;
}
/**
* Evaluate all verdicts together, allowing any to veto (i.e. REJECT) the operation.
* <ul>
* <li>If any vote is REJECT, then the result is a REJECT vote.
* <li>If no vote is REJECT, and any vote is AUTHORIZED, then the result is one of the AUTHORIZED votes.
* <li>If no vote is REJECT or AUTHORIZED, the result is a PROCEED vote.
* </ul>
*
* @return REJECT if any reject, AUTHORIZED if no REJECT and some AUTHORIZED, PROCEED if empty or all PROCEED
*/
public static ConsentOutcome parallelReduce(Stream<ConsentOutcome> 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<ConsentOutcome> theStream) {
return IConsentVote.serialReduce(ConsentOutcome.PROCEED, theStream);
}
public ConsentOperationStatusEnum getStatus() {
return myStatus;
}

View File

@ -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();
}
}

View File

@ -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.
* <ul>
* <li>If any vote is REJECT, then the result is REJECT.
* <li>If no vote is REJECT, and any vote is AUTHORIZED, then the result is AUTHORIZED.
* <li>If no vote is REJECT or AUTHORIZED, the result is PROCEED.
* </ul>
*
* @return REJECT if any reject, AUTHORIZED if no REJECT and some AUTHORIZED, PROCEED if empty or all PROCEED
*/
static <T extends IConsentVote> T parallelReduce(T theSeed, Stream<T> 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 extends IConsentVote> 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 extends IConsentVote> T serialReduce(T theSeed, Stream<T> 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 extends IConsentVote> T serialReduce(T theAccumulator, T theNextVoter) {
if (theAccumulator.getStatus().isAbstain()) {
return theNextVoter;
} else {
return theAccumulator;
}
}
private static <T extends IConsentVote> boolean isActiveVote(T nextVoter) {
return nextVoter.getStatus().isActiveVote();
}
}

View File

@ -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<IConsentService> myDelegates;
private final Function<Stream<ConsentOutcome>, ConsentOutcome> myVoteCombiner;
/**
* Combine several consent services allowing any to veto.
*/
public static @Nonnull MultiDelegateConsentService withParallelVoting(
@Nonnull List<IConsentService> theDelegateConsentServices) {
return new MultiDelegateConsentService(ConsentOutcome::parallelReduce, theDelegateConsentServices);
}
/**
* Combine several consent services with first non-PROCEED vote win.
*/
public static @Nonnull MultiDelegateConsentService withSerialVoting(
@Nonnull List<IConsentService> theDelegateConsentServices) {
return new MultiDelegateConsentService(ConsentOutcome::serialReduce, theDelegateConsentServices);
}
private MultiDelegateConsentService(
Function<Stream<ConsentOutcome>, ConsentOutcome> theVoteCombiner,
Collection<IConsentService> 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)));
}
}

View File

@ -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<ConsentOperationStatusEnum> 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<ConsentOperationStatusEnum> consentOperationStatusEnumStream = Arrays.stream(theInput.split(" +"))
.map(String::trim)
.map(ConsentOperationStatusEnum::valueOf);
// when
ConsentOperationStatusEnum result = ConsentOperationStatusEnum.parallelEvaluate(consentOperationStatusEnumStream);
assertEquals(theExpectedResult, result);
}
@Test
void testStrengthOrder() {

View File

@ -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);
}
}

View File

@ -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<ConsentOperationStatusEnum> consentOperationStatusEnumStream = splitEnumsToStream(theInput);
// when
ConsentOperationStatusEnum result = ConsentOperationStatusEnum.serialReduce(consentOperationStatusEnumStream);
assertEquals(theExpectedResult, result);
}
static @Nonnull Stream<ConsentOperationStatusEnum> 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<ConsentOperationStatusEnum> consentOperationStatusEnumStream = splitEnumsToStream(theInput);
// when
ConsentOperationStatusEnum result = ConsentOperationStatusEnum.parallelReduce(consentOperationStatusEnumStream);
assertEquals(theExpectedResult, result);
}
}

View File

@ -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<IConsentService> 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<IConsentService> 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;
}
};
}
}