VYPR
Critical severity9.1GHSA Advisory· Published May 15, 2026

CVE-2026-41258

CVE-2026-41258

Description

OpenMRS is an open source electronic medical record system platform. From 2.7.0 to before 2.7.9 and 2.8.6, the ConceptReferenceRangeUtility.evaluateCriteria() method in OpenMRS Core evaluates database-stored criteria strings as Apache Velocity templates without any sandbox configuration. The VelocityEngine is initialized with only logging properties and noSecureUberspector, leaving the default UberspectImpl in place, which allows unrestricted Java reflection through template expressions. A user with the Manage Concepts privilege can store a malicious Velocity template expression in a concept's reference range criteria field. This payload is then executed automatically whenever a user or API call validates an observation against the affected concept. The Velocity context exposes $patient (the Person / Patient object), $obs (the Obs object), and $fn (the ConceptReferenceRangeUtility instance with access to the full OpenMRS service layer). This vulnerability is fixed in 2.7.9 and 2.8.6.

Affected products

1

Patches

1
8d1c193

TRUNK-6610: Improve the Concept Reference Range code (#6024)

2 files changed · +823 332
  • api/src/main/java/org/openmrs/util/ConceptReferenceRangeUtility.java+360 332 modified
    @@ -9,21 +9,19 @@
      */
     package org.openmrs.util;
     
    -import java.io.StringWriter;
     import java.time.LocalDate;
     import java.time.ZoneId;
     import java.time.temporal.ChronoUnit;
     import java.util.ArrayList;
     import java.util.Collections;
     import java.util.Date;
    +import java.util.HashMap;
     import java.util.List;
     import java.util.Locale;
    -import java.util.Properties;
    +import java.util.Map;
    +import java.util.concurrent.TimeUnit;
     
     import org.apache.commons.lang3.StringUtils;
    -import org.apache.velocity.VelocityContext;
    -import org.apache.velocity.app.VelocityEngine;
    -import org.apache.velocity.exception.ParseErrorException;
     import org.joda.time.LocalTime;
     import org.openmrs.Concept;
     import org.openmrs.ConceptReferenceRangeContext;
    @@ -35,6 +33,18 @@
     import org.openmrs.api.APIException;
     import org.openmrs.api.context.Context;
     import org.openmrs.api.db.hibernate.HibernateUtil;
    +import org.springframework.expression.Expression;
    +import org.springframework.expression.ExpressionParser;
    +import org.springframework.expression.spel.SpelEvaluationException;
    +import org.springframework.expression.spel.SpelMessage;
    +import org.springframework.expression.spel.standard.SpelExpressionParser;
    +import org.springframework.expression.spel.support.DataBindingMethodResolver;
    +import org.springframework.expression.spel.support.DataBindingPropertyAccessor;
    +import org.springframework.expression.spel.support.MapAccessor;
    +import org.springframework.expression.spel.support.SimpleEvaluationContext;
    +
    +import com.github.benmanes.caffeine.cache.Cache;
    +import com.github.benmanes.caffeine.cache.Caffeine;
     
     /**
      * A utility class that evaluates the concept ranges
    @@ -43,7 +53,29 @@
      */
     public class ConceptReferenceRangeUtility {
     
    -	private final long NULL_DATE_RETURN_VALUE = -1;
    +	/**
    +	 * A local-only cache for expressions, which should alleviate parsing overhead in hot loops, i.e.,
    +	 * if the same expressions are evaluated multiple times within a relatively short succession.
    +	 * Expires each element 5 minutes after its last access.
    +	 */
    +	private static final Cache<String, Expression> EXPRESSION_CACHE = Caffeine.newBuilder().maximumSize(20000)
    +	        .expireAfterAccess(5, TimeUnit.MINUTES).build();
    +
    +	/**
    +	 * {@link ExpressionParser} instance used by the {@link ConceptReferenceRangeUtility} to parse
    +	 * expressions
    +	 */
    +	private static final ExpressionParser PARSER = new SpelExpressionParser();
    +
    +	/**
    +	 * Static {@link org.springframework.expression.EvaluationContext} which is used to run evaluations.
    +	 * This class is thread-safe, so shareable.
    +	 */
    +	private static final SimpleEvaluationContext EVAL_CONTEXT = SimpleEvaluationContext
    +	        .forPropertyAccessors(new MapAccessor(), DataBindingPropertyAccessor.forReadOnlyAccess())
    +	        .withMethodResolvers(DataBindingMethodResolver.forInstanceMethodInvocation()).build();
    +
    +	private final CriteriaFunctions functions = new CriteriaFunctions();
     
     	public ConceptReferenceRangeUtility() {
     	}
    @@ -96,387 +128,383 @@ public boolean evaluateCriteria(String criteria, ConceptReferenceRangeContext co
     			throw new IllegalArgumentException("Failed to evaluate criteria with reason: criteria is empty");
     		}
     
    -		VelocityContext velocityContext = new VelocityContext();
    -		velocityContext.put("fn", this);
    -		velocityContext.put("patient", HibernateUtil.getRealObjectFromProxy(context.getPerson()));
    -		velocityContext.put("context", context);
    -
    -		velocityContext.put("obs", context.getObs());
    -		velocityContext.put("encounter", context.getEncounter());
    -		velocityContext.put("date", context.getDate());
    -
    -		VelocityEngine velocityEngine = new VelocityEngine();
    -		try {
    -			Properties props = new Properties();
    -			props.put("runtime.log.logsystem.log4j.category", "velocity");
    -			props.put("runtime.log.logsystem.log4j.logger", "velocity");
    -			velocityEngine.init(props);
    -		} catch (Exception e) {
    -			throw new APIException("Failed to create the velocity engine: " + e.getMessage(), e);
    -		}
    -
    -		StringWriter writer = new StringWriter();
    -		String wrappedCriteria = "#set( $criteria = " + criteria + " )$criteria";
    +		Map<String, Object> root = new HashMap<>();
    +		root.put("$fn", functions);
    +		root.put("$patient", HibernateUtil.getRealObjectFromProxy(context.getPerson()));
    +		root.put("$context", context);
    +		root.put("$obs", context.getObs());
    +		root.put("$encounter", context.getEncounter());
    +		root.put("$date", context.getDate());
     
     		try {
    -			velocityEngine.evaluate(velocityContext, writer, ConceptReferenceRangeUtility.class.getName(), wrappedCriteria);
    -			return Boolean.parseBoolean(writer.toString());
    -		} catch (ParseErrorException e) {
    -			throw new APIException("An error occurred while evaluating criteria. Invalid criteria: " + criteria, e);
    +			Expression expression = EXPRESSION_CACHE.get(criteria, PARSER::parseExpression);
    +			Boolean result = expression.getValue(EVAL_CONTEXT, root, Boolean.class);
    +			return result != null && result;
    +		} catch (SpelEvaluationException e) {
    +			SpelMessage msg = e.getMessageCode();
    +			if (msg == SpelMessage.METHOD_CALL_ON_NULL_OBJECT_NOT_ALLOWED
    +			        || msg == SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE_ON_NULL) {
    +				return false;
    +			}
    +			throw new APIException("An error occurred while evaluating criteria: " + criteria, e);
     		} catch (Exception e) {
    -			throw new APIException("An error occurred while evaluating criteria: ", e);
    +			throw new APIException("An error occurred while evaluating criteria: " + criteria, e);
     		}
     	}
     
     	/**
    -	 * Gets the latest Obs by concept.
    +	 * Helper functions available as {@code $fn} in concept reference range criteria expressions.
    +	 * <p>
    +	 * This class is intentionally separate from the outer class so that {@code evaluateCriteria} is not
    +	 * callable from within expressions.
     	 *
    -	 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
    -	 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434"
    -	 * @param person person to get obs for
    -	 * @return Obs latest Obs
    +	 * @since 2.7.9, 2.8.6, 2.9.0, 3.0.0
     	 */
    -	public Obs getLatestObs(String conceptRef, Person person) {
    -		if (person == null) {
    +	static class CriteriaFunctions {
    +
    +		private final long NULL_DATE_RETURN_VALUE = -1;
    +
    +		/**
    +		 * Gets the latest Obs by concept.
    +		 *
    +		 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
    +		 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434"
    +		 * @param person person to get obs for
    +		 * @return Obs latest Obs
    +		 */
    +		public Obs getLatestObs(String conceptRef, Person person) {
    +			if (person == null) {
    +				return null;
    +			}
    +			Concept concept = Context.getConceptService().getConceptByReference(conceptRef);
    +
    +			if (concept != null) {
    +				List<Obs> observations = Context.getObsService().getObservations(Collections.singletonList(person), null,
    +				    Collections.singletonList(concept), null, null, null, Collections.singletonList("dateCreated"), 1, null,
    +				    null, null, false);
    +
    +				return observations.isEmpty() ? null : observations.get(0);
    +			}
    +
     			return null;
     		}
    -		Concept concept = Context.getConceptService().getConceptByReference(conceptRef);
     
    -		if (concept != null) {
    -			List<Obs> observations = Context.getObsService().getObservations(Collections.singletonList(person), null,
    -			    Collections.singletonList(concept), null, null, null, Collections.singletonList("dateCreated"), 1, null,
    -			    null, null, false);
    +		/**
    +		 * Gets the time of the day in hours.
    +		 *
    +		 * @return the hour of the day in 24hr format (e.g. 14 to mean 2pm)
    +		 */
    +		public int getCurrentHour() {
    +			return LocalTime.now().getHourOfDay();
    +		}
     
    -			return observations.isEmpty() ? null : observations.get(0);
    +		/**
    +		 * Retrieves the most relevant Obs for the given current Obs and conceptRef. If the current Obs
    +		 * contains a valid value (coded, numeric, date, text etc.) and the concept in Obs is the same as
    +		 * the supplied concept, the method returns the current Obs. Otherwise, it fetches the latest Obs
    +		 * for the supplied concept and patient.
    +		 *
    +		 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName
    +		 * @param currentObs the current Obs being evaluated
    +		 * @return the most relevant Obs based on the current Obs, or the latest Obs if the current one has
    +		 *         no valid value
    +		 */
    +		public Obs getCurrentObs(String conceptRef, Obs currentObs) {
    +			Concept concept = Context.getConceptService().getConceptByReference(conceptRef);
    +
    +			if (concept != null && concept.equals(currentObs.getConcept())
    +			        && !currentObs.getValueAsString(Locale.ENGLISH).isEmpty()) {
    +				return currentObs;
    +			} else {
    +				return getLatestObs(conceptRef, currentObs.getPerson());
    +			}
     		}
     
    -		return null;
    -	}
    +		/**
    +		 * Gets the person's latest observation date for a given concept
    +		 *
    +		 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
    +		 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434"
    +		 * @param person the person
    +		 * @return the observation date
    +		 * @since 2.7.0
    +		 */
    +		public Date getLatestObsDate(String conceptRef, Person person) {
    +			Obs obs = getLatestObs(conceptRef, person);
    +			if (obs == null) {
    +				return null;
    +			}
     
    -	/**
    -	 * Gets the time of the day in hours.
    -	 *
    -	 * @return the hour of the day in 24hr format (e.g. 14 to mean 2pm)
    -	 */
    -	public int getCurrentHour() {
    -		return LocalTime.now().getHourOfDay();
    -	}
    +			Date date = obs.getValueDate();
    +			if (date == null) {
    +				date = obs.getValueDatetime();
    +			}
     
    -	/**
    -	 * Retrieves the most relevant Obs for the given current Obs and conceptRef. If the current Obs
    -	 * contains a valid value (coded, numeric, date, text e.t.c) and the concept in Obs is the same as
    -	 * the supplied concept, the method returns the current Obs. Otherwise, it fetches the latest Obs
    -	 * for the supplied concept and patient.
    -	 *
    -	 * @param currentObs the current Obs being evaluated
    -	 * @return the most relevant Obs based on the current Obs, or the latest Obs if the current one has
    -	 *         no valid value
    -	 */
    -	public Obs getCurrentObs(String conceptRef, Obs currentObs) {
    -		Concept concept = Context.getConceptService().getConceptByReference(conceptRef);
    -
    -		if (currentObs.getValueAsString(Locale.ENGLISH).isEmpty()
    -		        && (concept != null && concept == currentObs.getConcept())) {
    -			return currentObs;
    -		} else {
    -			return getLatestObs(conceptRef, currentObs.getPerson());
    +			return date;
     		}
    -	}
     
    -	/**
    -	 * Gets the person's latest observation date for a given concept
    -	 *
    -	 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
    -	 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434"
    -	 * @param person the person
    -	 * @return the observation date
    -	 * @since 2.7.8
    -	 */
    -	public Date getLatestObsDate(String conceptRef, Person person) {
    -		Obs obs = getLatestObs(conceptRef, person);
    -		if (obs == null) {
    -			return null;
    -		}
    +		/**
    +		 * Checks if an observation's value coded answer is equal to a given concept
    +		 *
    +		 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
    +		 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434" for the observation's question
    +		 * @param person the person
    +		 * @param answerConceptRef can be either concept uuid or conceptMap's code and sourceName for the
    +		 *            observation's coded answer
    +		 * @return true if the given concept is equal to the observation's value coded answer
    +		 * @since 2.7.0
    +		 */
    +		public boolean isObsValueCodedAnswer(String conceptRef, Person person, String answerConceptRef) {
    +			Obs obs = getLatestObs(conceptRef, person);
    +			if (obs == null) {
    +				return false;
    +			}
     
    -		Date date = obs.getValueDate();
    -		if (date == null) {
    -			date = obs.getValueDatetime();
    -		}
    +			Concept valueCoded = obs.getValueCoded();
    +			if (valueCoded == null) {
    +				return false;
    +			}
     
    -		return date;
    -	}
    +			Concept answerConcept = Context.getConceptService().getConceptByReference(answerConceptRef);
    +			if (answerConcept == null) {
    +				return false;
    +			}
     
    -	/**
    -	 * Checks if an observation's value coded answer is equal to a given concept
    -	 *
    -	 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
    -	 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434" for the observation's question
    -	 * @param person the person
    -	 * @param answerConceptRef can be either concept uuid or conceptMap's code and sourceName e.g
    -	 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434" for the observation's coded
    -	 *            answer
    -	 * @return true if the given concept is equal to the observation's value coded answer
    -	 * @since 2.7.8
    -	 */
    -	public boolean isObsValueCodedAnswer(String conceptRef, Person person, String answerConceptRef) {
    -		Obs obs = getLatestObs(conceptRef, person);
    -		if (obs == null) {
    -			return false;
    +			return valueCoded.equals(answerConcept);
     		}
     
    -		Concept valudeCoded = obs.getValueCoded();
    -		if (valudeCoded == null) {
    -			return false;
    +		/**
    +		 * Gets the number of days from the person's latest observation date value for a given concept to
    +		 * the current date
    +		 *
    +		 * @param conceptRef concept uuid or conceptMap code and sourceName
    +		 * @param person the person
    +		 * @return the number of days
    +		 * @since 2.7.0
    +		 */
    +		public long getObsDays(String conceptRef, Person person) {
    +			Date date = getLatestObsDate(conceptRef, person);
    +			if (date == null) {
    +				return NULL_DATE_RETURN_VALUE;
    +			}
    +			return getDays(date);
     		}
     
    -		Concept answerConcept = Context.getConceptService().getConceptByReference(answerConceptRef);
    -		if (answerConcept == null) {
    -			return false;
    +		/**
    +		 * Gets the number of weeks from the person's latest observation date value for a given concept to
    +		 * the current date
    +		 *
    +		 * @param conceptRef concept uuid or conceptMap code and sourceName
    +		 * @param person the person
    +		 * @return the number of weeks
    +		 * @since 2.7.0
    +		 */
    +		public long getObsWeeks(String conceptRef, Person person) {
    +			Date date = getLatestObsDate(conceptRef, person);
    +			if (date == null) {
    +				return NULL_DATE_RETURN_VALUE;
    +			}
    +			return getWeeks(date);
     		}
     
    -		return valudeCoded.equals(answerConcept);
    -	}
    -
    -	/**
    -	 * Gets the number of days from the person's latest observation date value for a given concept to
    -	 * the current date
    -	 *
    -	 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
    -	 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434"
    -	 * @param person the person
    -	 * @return the number of days
    -	 * @since 2.7.8
    -	 */
    -	public long getObsDays(String conceptRef, Person person) {
    -		Date date = getLatestObsDate(conceptRef, person);
    -		if (date == null) {
    -			return NULL_DATE_RETURN_VALUE;
    +		/**
    +		 * Gets the number of months from the person's latest observation date value for a given concept to
    +		 * the current date
    +		 *
    +		 * @param conceptRef concept uuid or conceptMap code and sourceName
    +		 * @param person the person
    +		 * @return the number of months
    +		 * @since 2.7.0
    +		 */
    +		public long getObsMonths(String conceptRef, Person person) {
    +			Date date = getLatestObsDate(conceptRef, person);
    +			if (date == null) {
    +				return NULL_DATE_RETURN_VALUE;
    +			}
    +			return getMonths(date);
     		}
    -		return this.getDays(date);
    -	}
     
    -	/**
    -	 * Gets the number of weeks from the person's latest observation date value for a given concept to
    -	 * the current date
    -	 *
    -	 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
    -	 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434"
    -	 * @param person the person
    -	 * @return the number of weeks
    -	 * @since 2.7.8
    -	 */
    -	public long getObsWeeks(String conceptRef, Person person) {
    -		Date date = getLatestObsDate(conceptRef, person);
    -		if (date == null) {
    -			return NULL_DATE_RETURN_VALUE;
    +		/**
    +		 * Gets the number of years from the person's latest observation date value for a given concept to
    +		 * the current date
    +		 *
    +		 * @param conceptRef concept uuid or conceptMap code and sourceName
    +		 * @param person the person
    +		 * @return the number of years
    +		 * @since 2.7.0
    +		 */
    +		public long getObsYears(String conceptRef, Person person) {
    +			Date date = getLatestObsDate(conceptRef, person);
    +			if (date == null) {
    +				return NULL_DATE_RETURN_VALUE;
    +			}
    +			return getYears(date);
     		}
    -		return this.getWeeks(date);
    -	}
     
    -	/**
    -	 * Gets the number of months from the person's latest observation date value for a given concept to
    -	 * the current date
    -	 *
    -	 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
    -	 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434"
    -	 * @param person the person
    -	 * @return the number of months
    -	 * @since 2.7.8
    -	 */
    -	public long getObsMonths(String conceptRef, Person person) {
    -		Date date = getLatestObsDate(conceptRef, person);
    -		if (date == null) {
    -			return NULL_DATE_RETURN_VALUE;
    +		/**
    +		 * Gets the number of days between two given dates
    +		 *
    +		 * @param fromDate the date from which to start counting
    +		 * @param toDate the date up to which to stop counting
    +		 * @return the number of days between
    +		 * @since 2.7.0
    +		 */
    +		public long getDaysBetween(Date fromDate, Date toDate) {
    +			if (fromDate == null || toDate == null) {
    +				return NULL_DATE_RETURN_VALUE;
    +			}
    +			return ChronoUnit.DAYS.between(toLocalDate(fromDate), toLocalDate(toDate));
     		}
    -		return this.getMonths(date);
    -	}
     
    -	/**
    -	 * Gets the number of years from the person's latest observation date value for a given concept to
    -	 * the current date
    -	 *
    -	 * @param conceptRef can be either concept uuid or conceptMap's code and sourceName e.g
    -	 *            "bac25fd5-c143-4e43-bffe-4eb1e7efb6ce" or "CIEL:1434"
    -	 * @param person the person
    -	 * @return the number of years
    -	 * @since 2.7.8
    -	 */
    -	public long getObsYears(String conceptRef, Person person) {
    -		Date date = getLatestObsDate(conceptRef, person);
    -		if (date == null) {
    -			return NULL_DATE_RETURN_VALUE;
    +		/**
    +		 * Gets the number of weeks between two given dates
    +		 *
    +		 * @param fromDate the date from which to start counting
    +		 * @param toDate the date up to which to stop counting
    +		 * @return the number of weeks between
    +		 * @since 2.7.0
    +		 */
    +		public long getWeeksBetween(Date fromDate, Date toDate) {
    +			if (fromDate == null || toDate == null) {
    +				return NULL_DATE_RETURN_VALUE;
    +			}
    +			return ChronoUnit.WEEKS.between(toLocalDate(fromDate), toLocalDate(toDate));
     		}
    -		return this.getYears(date);
    -	}
     
    -	/**
    -	 * Gets the number of days between two given dates
    -	 *
    -	 * @param fromDate the date from which to start counting
    -	 * @param toDate the date up to which to stop counting
    -	 * @return the number of days between
    -	 * @since 2.7.8
    -	 */
    -	public long getDaysBetween(Date fromDate, Date toDate) {
    -		if (fromDate == null || toDate == null) {
    -			return NULL_DATE_RETURN_VALUE;
    +		/**
    +		 * Gets the number of months between two given dates
    +		 *
    +		 * @param fromDate the date from which to start counting
    +		 * @param toDate the date up to which to stop counting
    +		 * @return the number of months between
    +		 * @since 2.7.0
    +		 */
    +		public long getMonthsBetween(Date fromDate, Date toDate) {
    +			if (fromDate == null || toDate == null) {
    +				return NULL_DATE_RETURN_VALUE;
    +			}
    +			return ChronoUnit.MONTHS.between(toLocalDate(fromDate), toLocalDate(toDate));
     		}
    -		return ChronoUnit.DAYS.between(toLocalDate(fromDate), toLocalDate(toDate));
    -	}
     
    -	/**
    -	 * Gets the number of weeks between two given dates
    -	 *
    -	 * @param fromDate the date from which to start counting
    -	 * @param toDate the date up to which to stop counting
    -	 * @return the number of weeks between
    -	 * @since 2.7.8
    -	 */
    -	public long getWeeksBetween(Date fromDate, Date toDate) {
    -		if (fromDate == null || toDate == null) {
    -			return NULL_DATE_RETURN_VALUE;
    +		/**
    +		 * Gets the number of years between two given dates
    +		 *
    +		 * @param fromDate the date from which to start counting
    +		 * @param toDate the date up to which to stop counting
    +		 * @return the number of years between
    +		 * @since 2.7.0
    +		 */
    +		public long getYearsBetween(Date fromDate, Date toDate) {
    +			if (fromDate == null || toDate == null) {
    +				return NULL_DATE_RETURN_VALUE;
    +			}
    +			return ChronoUnit.YEARS.between(toLocalDate(fromDate), toLocalDate(toDate));
     		}
    -		return ChronoUnit.WEEKS.between(toLocalDate(fromDate), toLocalDate(toDate));
    -	}
     
    -	/**
    -	 * Gets the number of months between two given dates
    -	 *
    -	 * @param fromDate the date from which to start counting
    -	 * @param toDate the date up to which to stop counting
    -	 * @return the number of months between
    -	 * @since 2.7.8
    -	 */
    -	public long getMonthsBetween(Date fromDate, Date toDate) {
    -		if (fromDate == null || toDate == null) {
    -			return NULL_DATE_RETURN_VALUE;
    +		/**
    +		 * Gets the number of days from a given date up to the current date.
    +		 *
    +		 * @param fromDate the date from which to start counting
    +		 * @return the number of days
    +		 * @since 2.7.0
    +		 */
    +		public long getDays(Date fromDate) {
    +			return getDaysBetween(fromDate, new Date());
     		}
    -		return ChronoUnit.MONTHS.between(toLocalDate(fromDate), toLocalDate(toDate));
    -	}
     
    -	/**
    -	 * Gets the number of years between two given dates
    -	 *
    -	 * @param fromDate the date from which to start counting
    -	 * @param toDate the date up to which to stop counting
    -	 * @return the number of years between
    -	 * @since 2.7.8
    -	 */
    -	public long getYearsBetween(Date fromDate, Date toDate) {
    -		if (fromDate == null || toDate == null) {
    -			return NULL_DATE_RETURN_VALUE;
    +		/**
    +		 * Gets the number of weeks from a given date up to the current date.
    +		 *
    +		 * @param fromDate the date from which to start counting
    +		 * @return the number of weeks
    +		 * @since 2.7.0
    +		 */
    +		public long getWeeks(Date fromDate) {
    +			return getWeeksBetween(fromDate, new Date());
     		}
    -		return ChronoUnit.YEARS.between(toLocalDate(fromDate), toLocalDate(toDate));
    -	}
    -
    -	/**
    -	 * Gets the number of days from a given date up to the current date.
    -	 *
    -	 * @param fromDate the date from which to start counting
    -	 * @return the number of days
    -	 * @since 2.7.8
    -	 */
    -	public long getDays(Date fromDate) {
    -		return getDaysBetween(fromDate, new Date());
    -	}
    -
    -	/**
    -	 * Gets the number of weeks from a given date up to the current date.
    -	 *
    -	 * @param fromDate the date from which to start counting
    -	 * @return the number of weeks
    -	 * @since 2.7.8
    -	 */
    -	public long getWeeks(Date fromDate) {
    -		return getWeeksBetween(fromDate, new Date());
    -	}
     
    -	/**
    -	 * Gets the number of months from a given date up to the current date.
    -	 *
    -	 * @param fromDate the date from which to start counting
    -	 * @return the number of months
    -	 * @since 2.7.8
    -	 */
    -	public long getMonths(Date fromDate) {
    -		return getMonthsBetween(fromDate, new Date());
    -	}
    -
    -	/**
    -	 * Gets the number of years from a given date up to the current date.
    -	 *
    -	 * @param fromDate the date from which to start counting
    -	 * @return the number of years
    -	 * @since 2.7.8
    -	 */
    -	public long getYears(Date fromDate) {
    -		return getYearsBetween(fromDate, new Date());
    -	}
    -
    -	/**
    -	 * Returns whether the patient is the specified program on the specified date
    -	 *
    -	 * @param uuid of program
    -	 * @param person the patient to test
    -	 * @param onDate the date to test whether the patient is in the program
    -	 * @return true if the patient is in the program on the specified date, false otherwise
    -	 * @since 2.8.3
    -	 */
    -	public boolean isEnrolledInProgram(String uuid, Person person, Date onDate) {
    -		if (person == null) {
    -			return false;
    +		/**
    +		 * Gets the number of months from a given date up to the current date.
    +		 *
    +		 * @param fromDate the date from which to start counting
    +		 * @return the number of months
    +		 * @since 2.7.0
    +		 */
    +		public long getMonths(Date fromDate) {
    +			return getMonthsBetween(fromDate, new Date());
     		}
    -		if (!(person.getIsPatient())) {
    -			return false;
    -		}
    -		return getPatientPrograms((Patient) person, onDate).stream().anyMatch(pp -> pp.getProgram().getUuid().equals(uuid));
    -	}
     
    -	/**
    -	 * Returns whether the patient is the specified program state on the specified date
    -	 *
    -	 * @param uuid of program state
    -	 * @param person the patient to test
    -	 * @param onDate the date to test whether the patient is in the program state
    -	 * @return true if the patient is in the program state on the specified date, false otherwise
    -	 * @since 2.8.3
    -	 */
    -	public boolean isInProgramState(String uuid, Person person, Date onDate) {
    -		if (person == null) {
    -			return false;
    +		/**
    +		 * Gets the number of years from a given date up to the current date.
    +		 *
    +		 * @param fromDate the date from which to start counting
    +		 * @return the number of years
    +		 * @since 2.7.0
    +		 */
    +		public long getYears(Date fromDate) {
    +			return getYearsBetween(fromDate, new Date());
     		}
    -		if (!(person.getIsPatient())) {
    -			return false;
    +
    +		/**
    +		 * Returns whether the patient is the specified program on the specified date
    +		 *
    +		 * @param uuid of program
    +		 * @param person the patient to test
    +		 * @param onDate the date to test whether the patient is in the program
    +		 * @return true if the patient is in the program on the specified date, false otherwise
    +		 * @since 2.7.0
    +		 */
    +		public boolean isEnrolledInProgram(String uuid, Person person, Date onDate) {
    +			if (person == null) {
    +				return false;
    +			}
    +			if (!(person.getIsPatient())) {
    +				return false;
    +			}
    +			return getPatientPrograms((Patient) person, onDate).stream()
    +			        .anyMatch(pp -> pp.getProgram().getUuid().equals(uuid));
     		}
     
    -		List<PatientProgram> patientPrograms = getPatientPrograms((Patient) person, onDate);
    -		List<PatientState> patientStates = new ArrayList<>();
    +		/**
    +		 * Returns whether the patient is the specified program state on the specified date
    +		 *
    +		 * @param uuid of program state
    +		 * @param person the patient to test
    +		 * @param onDate the date to test whether the patient is in the program state
    +		 * @return true if the patient is in the program state on the specified date, false otherwise
    +		 * @since 2.7.0
    +		 */
    +		public boolean isInProgramState(String uuid, Person person, Date onDate) {
    +			if (person == null) {
    +				return false;
    +			}
    +			if (!(person.getIsPatient())) {
    +				return false;
    +			}
     
    -		for (PatientProgram pp : patientPrograms) {
    -			for (PatientState state : pp.getStates()) {
    -				if (state.getActive(onDate)) {
    -					patientStates.add(state);
    +			List<PatientProgram> patientPrograms = getPatientPrograms((Patient) person, onDate);
    +			List<PatientState> patientStates = new ArrayList<>();
    +
    +			for (PatientProgram pp : patientPrograms) {
    +				for (PatientState state : pp.getStates()) {
    +					if (state.getActive(onDate)) {
    +						patientStates.add(state);
    +					}
     				}
     			}
    -		}
     
    -		return patientStates.stream().anyMatch(ps -> ps.getState().getUuid().equals(uuid));
    -	}
    +			return patientStates.stream().anyMatch(ps -> ps.getState().getUuid().equals(uuid));
    +		}
     
    -	/**
    -	 * Converts a java.util.Date to java.time.LocalDate
    -	 *
    -	 * @param date the java.util.Date
    -	 * @return the java.time.LocalDate
    -	 */
    -	private LocalDate toLocalDate(Date date) {
    -		return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
    -	}
    +		private LocalDate toLocalDate(Date date) {
    +			return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
    +		}
     
    -	private List<PatientProgram> getPatientPrograms(Patient patient, Date onDate) {
    -		if (onDate == null) {
    -			onDate = new Date();
    +		private List<PatientProgram> getPatientPrograms(Patient patient, Date onDate) {
    +			if (onDate == null) {
    +				onDate = new Date();
    +			}
    +			return Context.getProgramWorkflowService().getPatientPrograms(patient, null, null, onDate, onDate, null, false);
     		}
    -		return Context.getProgramWorkflowService().getPatientPrograms(patient, null, null, onDate, onDate, null, false);
     	}
     }
    
  • api/src/test/java/org/openmrs/util/ConceptReferenceRangeUtilityTest.java+463 0 modified
    @@ -110,6 +110,57 @@ public void testAgeInMonths_shouldReturnTrueIfAgeIsWithinRange() {
     		        .evaluateCriteria("$patient.getAgeInMonths() > 1 && $patient.getAgeInMonths() < 12", obs));
     	}
     
    +	@Test
    +	public void testAgeInDays_shouldReturnTrueIfAgeIsWithinRange() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.DAY_OF_YEAR, -5);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertTrue(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$patient.getAgeInDays() >= 0 && $patient.getAgeInDays() <= 7", obs));
    +	}
    +
    +	@Test
    +	public void testAgeInDays_shouldReturnFalseIfAgeIsOutsideRange() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.DAY_OF_YEAR, -10);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertFalse(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$patient.getAgeInDays() >= 0 && $patient.getAgeInDays() <= 7", obs));
    +	}
    +
    +	@Test
    +	public void testAgeInWeeks_shouldReturnTrueIfAgeMatches() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.WEEK_OF_YEAR, -2);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertTrue(conceptReferenceRangeUtility.evaluateCriteria("$patient.getAgeInWeeks() == 2", obs));
    +	}
    +
    +	@Test
    +	public void testMixedAgeMethods_shouldReturnTrueIfBothConditionsMatch() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.MONTH, -8);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertTrue(
    +		    conceptReferenceRangeUtility.evaluateCriteria("$patient.getAgeInMonths() >= 6 && $patient.getAge() < 2", obs));
    +	}
    +
     	@Test
     	public void testAgeInRange_shouldThrowExceptionIfCriteriaIsInvalid() {
     		calendar = Calendar.getInstance();
    @@ -175,6 +226,26 @@ public void testGenderMatch_shouldReturnFalseIfGenderIsNull() {
     		assertFalse(conceptReferenceRangeUtility.evaluateCriteria("$patient.getGender().equals('M')", obs));
     	}
     
    +	@Test
    +	public void testGenderMatch_shouldReturnTrueForDoubleQuoteEquality() {
    +		person.setGender("F");
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertTrue(conceptReferenceRangeUtility.evaluateCriteria("$patient.getGender() == \"F\"", obs));
    +	}
    +
    +	@Test
    +	public void testGenderMatch_shouldReturnFalseForDoubleQuoteEqualityWhenGenderDoesNotMatch() {
    +		person.setGender("M");
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertFalse(conceptReferenceRangeUtility.evaluateCriteria("$patient.getGender() == \"F\"", obs));
    +	}
    +
     	@Test
     	public void testAgeAndGenderMatch_shouldReturnTrueIfAgeAndGenderMatch() {
     		calendar = Calendar.getInstance();
    @@ -322,6 +393,52 @@ public void testObsValueMatch_shouldReturnFalseIfValueMismatch() {
     		        .evaluateCriteria("$fn.getLatestObs('CIEL:1234', $patient).getValueBoolean() == true", obs));
     	}
     
    +	@Test
    +	public void testNegation_shouldReturnTrueWhenAllNegatedConditionsAreFalse() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.YEAR, -25);
    +		person.setBirthdate(calendar.getTime());
    +		person.setGender("F");
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertTrue(
    +		    conceptReferenceRangeUtility.evaluateCriteria("$patient.getAge() > 18 && $patient.getGender() == \"F\" && "
    +		            + "!($fn.isObsValueCodedAnswer(\"CIEL:45\", $patient, \"CIEL:703\") "
    +		            + "|| $fn.isObsValueCodedAnswer(\"CIEL:1945\", $patient, \"CIEL:703\"))",
    +		        obs));
    +	}
    +
    +	@Test
    +	public void testNegation_shouldReturnFalseWhenNegatedConditionIsTrue() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.YEAR, -25);
    +		person.setBirthdate(calendar.getTime());
    +		person.setGender("F");
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		Concept questionConcept = new Concept(4900);
    +		Concept answerConcept = new Concept(900);
    +		obs.setValueCoded(answerConcept);
    +
    +		Mockito.when(conceptService.getConceptByReference("CIEL:45")).thenReturn(questionConcept);
    +		Mockito.when(conceptService.getConceptByReference("CIEL:703")).thenReturn(answerConcept);
    +
    +		Mockito.when(
    +		    obsService.getObservations(Collections.singletonList(person), null, Collections.singletonList(questionConcept),
    +		        null, null, null, Collections.singletonList("dateCreated"), 1, null, null, null, false))
    +		        .thenReturn(Collections.singletonList(obs));
    +
    +		assertFalse(
    +		    conceptReferenceRangeUtility.evaluateCriteria("$patient.getAge() > 18 && $patient.getGender() == \"F\" && "
    +		            + "!($fn.isObsValueCodedAnswer(\"CIEL:45\", $patient, \"CIEL:703\") "
    +		            + "|| $fn.isObsValueCodedAnswer(\"CIEL:1945\", $patient, \"CIEL:703\"))",
    +		        obs));
    +	}
    +
     	@Test
     	public void testTimeOfDay_shouldReturnTrueIfTimeOfDayMatches() {
     		// Freeze time at the current system time
    @@ -370,6 +487,25 @@ public void testRelevantObs_shouldReturnTrueIfCurrentObsHasValidNumericValue() {
     		    "$fn.getCurrentObs('bac25fd5-c143-4e43-bffe-4eb1e7efb6ce', $obs).getValueNumeric() >= 20", obs));
     	}
     
    +	@Test
    +	public void testRelevantObs_shouldReturnCurrentObsWhenItMatchesConceptAndHasValue() {
    +		Concept obsConcept = new Concept(5089);
    +		obsConcept.setDatatype(new ConceptDatatype(3));
    +
    +		Obs obs = new Obs();
    +		obs.setConcept(obsConcept);
    +		obs.setPerson(person);
    +		obs.setValueNumeric(42.0);
    +		obs.setObsDatetime(new Date());
    +		obs.setEncounter(new Encounter(3));
    +		obs.setLocation(new Location(1));
    +
    +		Mockito.when(conceptService.getConceptByReference("test-concept-ref")).thenReturn(obsConcept);
    +
    +		assertTrue(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$fn.getCurrentObs('test-concept-ref', $obs).getValueNumeric() == 42.0", obs));
    +	}
    +
     	@Test
     	public void testRelevantObs_shouldReturnTrueIfBMIIsInTheExpectedRange() {
     		Obs obs = buildObs();
    @@ -509,6 +645,267 @@ public void getObsWeeks_shouldReturnNegativeOneForNullValueDate() {
     		        .evaluateCriteria("$fn.getObsWeeks('bac25fd5-c143-4e43-bffe-4eb1e7efb6ce', $patient) == -1", obs));
     	}
     
    +	@Test
    +	public void getObsDays_shouldReturnNegativeOneForNullValueDate() {
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +		obs.setValueDate(null);
    +
    +		Concept concept = new Concept(4900);
    +
    +		Mockito.when(conceptService.getConceptByReference(Mockito.anyString())).thenReturn(concept);
    +
    +		Mockito.when(obsService.getObservations(Collections.singletonList(person), null, Collections.singletonList(concept),
    +		    null, null, null, Collections.singletonList("dateCreated"), 1, null, null, null, false))
    +		        .thenReturn(Collections.singletonList(obs));
    +
    +		assertTrue(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$fn.getObsDays('bac25fd5-c143-4e43-bffe-4eb1e7efb6ce', $patient) == -1", obs));
    +	}
    +
    +	@Test
    +	public void getObsMonths_shouldReturnNegativeOneForNullValueDate() {
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +		obs.setValueDate(null);
    +
    +		Concept concept = new Concept(4900);
    +
    +		Mockito.when(conceptService.getConceptByReference(Mockito.anyString())).thenReturn(concept);
    +
    +		Mockito.when(obsService.getObservations(Collections.singletonList(person), null, Collections.singletonList(concept),
    +		    null, null, null, Collections.singletonList("dateCreated"), 1, null, null, null, false))
    +		        .thenReturn(Collections.singletonList(obs));
    +
    +		assertTrue(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$fn.getObsMonths('bac25fd5-c143-4e43-bffe-4eb1e7efb6ce', $patient) == -1", obs));
    +	}
    +
    +	@Test
    +	public void getObsYears_shouldReturnNegativeOneForNullValueDate() {
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +		obs.setValueDate(null);
    +
    +		Concept concept = new Concept(4900);
    +
    +		Mockito.when(conceptService.getConceptByReference(Mockito.anyString())).thenReturn(concept);
    +
    +		Mockito.when(obsService.getObservations(Collections.singletonList(person), null, Collections.singletonList(concept),
    +		    null, null, null, Collections.singletonList("dateCreated"), 1, null, null, null, false))
    +		        .thenReturn(Collections.singletonList(obs));
    +
    +		assertTrue(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$fn.getObsYears('bac25fd5-c143-4e43-bffe-4eb1e7efb6ce', $patient) == -1", obs));
    +	}
    +
    +	@Test
    +	public void getDaysBetween_shouldReturnNegativeOneForNullDate() {
    +		person.setBirthdate(null);
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertTrue(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$fn.getDaysBetween($patient.birthdate, $obs.obsDatetime) == -1", obs));
    +	}
    +
    +	@Test
    +	public void getWeeksBetween_shouldReturnNegativeOneForNullDate() {
    +		person.setBirthdate(null);
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertTrue(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$fn.getWeeksBetween($patient.birthdate, $obs.obsDatetime) == -1", obs));
    +	}
    +
    +	@Test
    +	public void getMonthsBetween_shouldReturnNegativeOneForNullDate() {
    +		person.setBirthdate(null);
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertTrue(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$fn.getMonthsBetween($patient.birthdate, $obs.obsDatetime) == -1", obs));
    +	}
    +
    +	@Test
    +	public void getYearsBetween_shouldReturnNegativeOneForNullDate() {
    +		person.setBirthdate(null);
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertTrue(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$fn.getYearsBetween($patient.birthdate, $obs.obsDatetime) == -1", obs));
    +	}
    +
    +	@Test
    +	public void testDaysBetween_shouldSupportPropertyAccessAsMethodArguments() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.DAY_OF_YEAR, -15);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +		obs.setObsDatetime(new Date());
    +
    +		assertTrue(
    +		    conceptReferenceRangeUtility.evaluateCriteria("$fn.getDaysBetween($patient.birthdate, $obs.obsDatetime) >= 0 "
    +		            + "&& $fn.getDaysBetween($patient.birthdate, $obs.obsDatetime) < 30",
    +		        obs));
    +	}
    +
    +	@Test
    +	public void getWeeksBetween_shouldReturnNumberOfWeeksBetweenDates() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.WEEK_OF_YEAR, -10);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +		obs.setObsDatetime(new Date());
    +
    +		assertTrue(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$fn.getWeeksBetween($patient.birthdate, $obs.obsDatetime) == 10", obs));
    +	}
    +
    +	@Test
    +	public void getMonthsBetween_shouldReturnNumberOfMonthsBetweenDates() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.MONTH, -6);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +		obs.setObsDatetime(new Date());
    +
    +		assertTrue(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$fn.getMonthsBetween($patient.birthdate, $obs.obsDatetime) == 6", obs));
    +	}
    +
    +	@Test
    +	public void getYearsBetween_shouldReturnNumberOfYearsBetweenDates() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.YEAR, -3);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +		obs.setObsDatetime(new Date());
    +
    +		assertTrue(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$fn.getYearsBetween($patient.birthdate, $obs.obsDatetime) == 3", obs));
    +	}
    +
    +	@Test
    +	public void getDays_shouldReturnNumberOfDaysFromDateToNow() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.DAY_OF_YEAR, -45);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertTrue(conceptReferenceRangeUtility.evaluateCriteria("$fn.getDays($patient.birthdate) == 45", obs));
    +	}
    +
    +	@Test
    +	public void getWeeks_shouldReturnNumberOfWeeksFromDateToNow() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.WEEK_OF_YEAR, -8);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertTrue(conceptReferenceRangeUtility.evaluateCriteria("$fn.getWeeks($patient.birthdate) == 8", obs));
    +	}
    +
    +	@Test
    +	public void getMonths_shouldReturnNumberOfMonthsFromDateToNow() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.MONTH, -4);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertTrue(conceptReferenceRangeUtility.evaluateCriteria("$fn.getMonths($patient.birthdate) == 4", obs));
    +	}
    +
    +	@Test
    +	public void getYears_shouldReturnNumberOfYearsFromDateToNow() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.YEAR, -7);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertTrue(conceptReferenceRangeUtility.evaluateCriteria("$fn.getYears($patient.birthdate) == 7", obs));
    +	}
    +
    +	@Test
    +	public void getLatestObsDate_shouldReturnObservationDate() {
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.DAY_OF_YEAR, -30);
    +		obs.setValueDate(calendar.getTime());
    +
    +		Concept concept = new Concept(4900);
    +
    +		Mockito.when(conceptService.getConceptByReference(Mockito.anyString())).thenReturn(concept);
    +
    +		Mockito.when(obsService.getObservations(Collections.singletonList(person), null, Collections.singletonList(concept),
    +		    null, null, null, Collections.singletonList("dateCreated"), 1, null, null, null, false))
    +		        .thenReturn(Collections.singletonList(obs));
    +
    +		assertTrue(conceptReferenceRangeUtility.evaluateCriteria(
    +		    "$fn.getDays($fn.getLatestObsDate('bac25fd5-c143-4e43-bffe-4eb1e7efb6ce', $patient)) == 30", obs));
    +	}
    +
    +	@Test
    +	public void getLatestObsDate_shouldReturnNullForMissingObs() {
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertFalse(conceptReferenceRangeUtility
    +		        .evaluateCriteria("$fn.getLatestObsDate('bac25fd5-c143-4e43-bffe-4eb1e7efb6ce', $patient) != null", obs));
    +	}
    +
    +	@Test
    +	public void testAgeAndObs_shouldReturnTrueIfAgeAndObsConditionsMatch() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.DAY_OF_YEAR, -5);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		Obs obsWithDate = buildObs();
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.WEEK_OF_YEAR, -33);
    +		obsWithDate.setValueDate(calendar.getTime());
    +
    +		Concept concept = new Concept(4900);
    +
    +		Mockito.when(conceptService.getConceptByReference("CIEL:1427")).thenReturn(concept);
    +		Mockito.when(obsService.getObservations(Collections.singletonList(person), null, Collections.singletonList(concept),
    +		    null, null, null, Collections.singletonList("dateCreated"), 1, null, null, null, false))
    +		        .thenReturn(Collections.singletonList(obsWithDate));
    +
    +		assertTrue(
    +		    conceptReferenceRangeUtility.evaluateCriteria("$patient.getAgeInDays() >= 0 && $patient.getAgeInDays() <= 7 "
    +		            + "&& $fn.getObsWeeks('CIEL:1427', $patient) >= 32",
    +		        obs));
    +	}
    +
     	// all the following tests use data from the standard test dataset instead of mocking
     	@Test
     	public void isEnrolledInProgram_shouldReturnTrueIfPatientIsEnrolledInProgram() {
    @@ -588,6 +985,72 @@ public void isInProgramState_shouldReturnFalseIfPersonIsNotPatient() {
     		    "$fn.isInProgramState('e938129e-248a-482a-acea-f85127251472', $patient, $obs.obsDatetime)", obs));
     	}
     
    +	@Test
    +	public void testSandbox_shouldBlockTypeReferencesPreventingRCE() {
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertThrows(APIException.class,
    +		    () -> conceptReferenceRangeUtility.evaluateCriteria("T(java.lang.Runtime).getRuntime()", obs));
    +	}
    +
    +	@Test
    +	public void testSandbox_shouldBlockConstructorInvocation() {
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertThrows(APIException.class, () -> conceptReferenceRangeUtility
    +		        .evaluateCriteria("new java.net.URL('http://evil.com').openConnection()", obs));
    +	}
    +
    +	@Test
    +	public void testSandbox_shouldBlockBeanReferences() {
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertThrows(APIException.class, () -> conceptReferenceRangeUtility.evaluateCriteria("@systemProperties", obs));
    +	}
    +
    +	@Test
    +	public void testSandbox_shouldBlockReflectionViaGetClass() {
    +		calendar = Calendar.getInstance();
    +		calendar.add(Calendar.YEAR, -5);
    +		person.setBirthdate(calendar.getTime());
    +
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertThrows(APIException.class,
    +		    () -> conceptReferenceRangeUtility.evaluateCriteria("$patient.getClass().forName('java.lang.Runtime')", obs));
    +	}
    +
    +	@Test
    +	public void testSandbox_shouldBlockProcessBuilderViaTypeReference() {
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertThrows(APIException.class,
    +		    () -> conceptReferenceRangeUtility.evaluateCriteria("T(java.lang.ProcessBuilder).new({'whoami'}).start()", obs));
    +	}
    +
    +	@Test
    +	public void testSandbox_shouldBlockURLConnectionViaTypeReference() {
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertThrows(APIException.class, () -> conceptReferenceRangeUtility
    +		        .evaluateCriteria("T(java.net.URL).new('http://evil.com').openConnection()", obs));
    +	}
    +
    +	@Test
    +	public void testSandbox_shouldNotExposeEvaluateCriteriaViaFn() {
    +		Obs obs = buildObs();
    +		obs.setPerson(person);
    +
    +		assertThrows(APIException.class,
    +		    () -> conceptReferenceRangeUtility.evaluateCriteria("$fn.evaluateCriteria('$patient.getAge() > 0', $obs)", obs));
    +	}
    +
     	private Obs buildObs() {
     		Concept concept = new Concept(5089);
     		concept.setDatatype(new ConceptDatatype(3));
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.