summaryrefslogtreecommitdiffstats
path: root/lib/common/tests/rules
diff options
context:
space:
mode:
Diffstat (limited to 'lib/common/tests/rules')
-rw-r--r--lib/common/tests/rules/Makefile.am29
-rw-r--r--lib/common/tests/rules/pcmk__cmp_by_type_test.c102
-rw-r--r--lib/common/tests/rules/pcmk__evaluate_attr_expression_test.c831
-rw-r--r--lib/common/tests/rules/pcmk__evaluate_condition_test.c197
-rw-r--r--lib/common/tests/rules/pcmk__evaluate_date_expression_test.c684
-rw-r--r--lib/common/tests/rules/pcmk__evaluate_date_spec_test.c231
-rw-r--r--lib/common/tests/rules/pcmk__evaluate_op_expression_test.c207
-rw-r--r--lib/common/tests/rules/pcmk__evaluate_rsc_expression_test.c227
-rw-r--r--lib/common/tests/rules/pcmk__parse_combine_test.c52
-rw-r--r--lib/common/tests/rules/pcmk__parse_comparison_test.c72
-rw-r--r--lib/common/tests/rules/pcmk__parse_source_test.c62
-rw-r--r--lib/common/tests/rules/pcmk__parse_type_test.c127
-rw-r--r--lib/common/tests/rules/pcmk__replace_submatches_test.c81
-rw-r--r--lib/common/tests/rules/pcmk__unpack_duration_test.c120
-rw-r--r--lib/common/tests/rules/pcmk_evaluate_rule_test.c379
15 files changed, 3401 insertions, 0 deletions
diff --git a/lib/common/tests/rules/Makefile.am b/lib/common/tests/rules/Makefile.am
new file mode 100644
index 0000000..4163037
--- /dev/null
+++ b/lib/common/tests/rules/Makefile.am
@@ -0,0 +1,29 @@
+#
+# Copyright 2020-2024 the Pacemaker project contributors
+#
+# The version control history for this file may have further details.
+#
+# This source code is licensed under the GNU General Public License version 2
+# or later (GPLv2+) WITHOUT ANY WARRANTY.
+#
+
+include $(top_srcdir)/mk/tap.mk
+include $(top_srcdir)/mk/unittest.mk
+
+# Add "_test" to the end of all test program names to simplify .gitignore.
+check_PROGRAMS = pcmk__cmp_by_type_test \
+ pcmk__evaluate_attr_expression_test \
+ pcmk__evaluate_date_expression_test \
+ pcmk__evaluate_date_spec_test \
+ pcmk__evaluate_condition_test \
+ pcmk__evaluate_op_expression_test \
+ pcmk__evaluate_rsc_expression_test \
+ pcmk__parse_combine_test \
+ pcmk__parse_comparison_test \
+ pcmk__parse_source_test \
+ pcmk__parse_type_test \
+ pcmk__replace_submatches_test \
+ pcmk__unpack_duration_test \
+ pcmk_evaluate_rule_test
+
+TESTS = $(check_PROGRAMS)
diff --git a/lib/common/tests/rules/pcmk__cmp_by_type_test.c b/lib/common/tests/rules/pcmk__cmp_by_type_test.c
new file mode 100644
index 0000000..cf468f1
--- /dev/null
+++ b/lib/common/tests/rules/pcmk__cmp_by_type_test.c
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <limits.h> // INT_MIN, INT_MAX
+
+#include <crm/common/util.h> // crm_strdup_printf()
+#include <crm/common/rules_internal.h>
+#include <crm/common/unittest_internal.h>
+#include "crmcommon_private.h"
+
+static void
+null_compares_lesser(void **state)
+{
+ assert_int_equal(pcmk__cmp_by_type(NULL, NULL, pcmk__type_string), 0);
+ assert_true(pcmk__cmp_by_type("0", NULL, pcmk__type_integer) > 0);
+ assert_true(pcmk__cmp_by_type(NULL, "0", pcmk__type_number) < 0);
+}
+
+static void
+invalid_compares_equal(void **state)
+{
+ assert_int_equal(pcmk__cmp_by_type("0", "1", pcmk__type_unknown), 0);
+ assert_int_equal(pcmk__cmp_by_type("hi", "bye", pcmk__type_unknown), 0);
+ assert_int_equal(pcmk__cmp_by_type("-1.0", "2.0", pcmk__type_unknown), 0);
+}
+
+static void
+compare_string_type(void **state)
+{
+ assert_int_equal(pcmk__cmp_by_type("bye", "bye", pcmk__type_string), 0);
+ assert_int_equal(pcmk__cmp_by_type("bye", "BYE", pcmk__type_string), 0);
+ assert_true(pcmk__cmp_by_type("bye", "hello", pcmk__type_string) < 0);
+ assert_true(pcmk__cmp_by_type("bye", "HELLO", pcmk__type_string) < 0);
+ assert_true(pcmk__cmp_by_type("bye", "boo", pcmk__type_string) > 0);
+ assert_true(pcmk__cmp_by_type("bye", "Boo", pcmk__type_string) > 0);
+}
+
+static void
+compare_integer_type(void **state)
+{
+ char *int_min = crm_strdup_printf("%d", INT_MIN);
+ char *int_max = crm_strdup_printf("%d", INT_MAX);
+
+ assert_int_equal(pcmk__cmp_by_type("0", "0", pcmk__type_integer), 0);
+ assert_true(pcmk__cmp_by_type("0", "1", pcmk__type_integer) < 0);
+ assert_true(pcmk__cmp_by_type("1", "0", pcmk__type_integer) > 0);
+ assert_true(pcmk__cmp_by_type("3999", "399", pcmk__type_integer) > 0);
+ assert_true(pcmk__cmp_by_type(int_min, int_max, pcmk__type_integer) < 0);
+ assert_true(pcmk__cmp_by_type(int_max, int_min, pcmk__type_integer) > 0);
+ free(int_min);
+ free(int_max);
+
+ // Non-integers compare as strings
+ assert_int_equal(pcmk__cmp_by_type("0", "x", pcmk__type_integer),
+ pcmk__cmp_by_type("0", "x", pcmk__type_string));
+ assert_int_equal(pcmk__cmp_by_type("x", "0", pcmk__type_integer),
+ pcmk__cmp_by_type("x", "0", pcmk__type_string));
+ assert_int_equal(pcmk__cmp_by_type("x", "X", pcmk__type_integer),
+ pcmk__cmp_by_type("x", "X", pcmk__type_string));
+}
+
+static void
+compare_number_type(void **state)
+{
+ assert_int_equal(pcmk__cmp_by_type("0", "0.0", pcmk__type_number), 0);
+ assert_true(pcmk__cmp_by_type("0.345", "0.5", pcmk__type_number) < 0);
+ assert_true(pcmk__cmp_by_type("5", "3.1", pcmk__type_number) > 0);
+ assert_true(pcmk__cmp_by_type("3999", "399", pcmk__type_number) > 0);
+
+ // Non-numbers compare as strings
+ assert_int_equal(pcmk__cmp_by_type("0.0", "x", pcmk__type_number),
+ pcmk__cmp_by_type("0.0", "x", pcmk__type_string));
+ assert_int_equal(pcmk__cmp_by_type("x", "0.0", pcmk__type_number),
+ pcmk__cmp_by_type("x", "0.0", pcmk__type_string));
+ assert_int_equal(pcmk__cmp_by_type("x", "X", pcmk__type_number),
+ pcmk__cmp_by_type("x", "X", pcmk__type_string));
+}
+
+static void
+compare_version_type(void **state)
+{
+ assert_int_equal(pcmk__cmp_by_type("1.0", "1.0", pcmk__type_version), 0);
+ assert_true(pcmk__cmp_by_type("1.0.0", "1.0.1", pcmk__type_version) < 0);
+ assert_true(pcmk__cmp_by_type("5.0", "3.1.15", pcmk__type_version) > 0);
+ assert_true(pcmk__cmp_by_type("3999", "399", pcmk__type_version) > 0);
+}
+
+PCMK__UNIT_TEST(NULL, NULL,
+ cmocka_unit_test(null_compares_lesser),
+ cmocka_unit_test(invalid_compares_equal),
+ cmocka_unit_test(compare_string_type),
+ cmocka_unit_test(compare_integer_type),
+ cmocka_unit_test(compare_number_type),
+ cmocka_unit_test(compare_version_type))
diff --git a/lib/common/tests/rules/pcmk__evaluate_attr_expression_test.c b/lib/common/tests/rules/pcmk__evaluate_attr_expression_test.c
new file mode 100644
index 0000000..d28cb11
--- /dev/null
+++ b/lib/common/tests/rules/pcmk__evaluate_attr_expression_test.c
@@ -0,0 +1,831 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <stdio.h>
+#include <glib.h>
+
+#include <crm/common/xml.h>
+#include <crm/common/rules_internal.h>
+#include <crm/common/unittest_internal.h>
+#include "crmcommon_private.h"
+
+/*
+ * Shared data
+ */
+
+#define MATCHED_STRING "server-north"
+
+static const regmatch_t submatches[] = {
+ { .rm_so = 0, .rm_eo = 12 }, // %0 = Entire string
+ { .rm_so = 7, .rm_eo = 12 }, // %1 = "north"
+};
+
+static pcmk_rule_input_t rule_input = {
+ // These are the only members used to evaluate attribute expressions
+
+ // Used to replace submatches in attribute name
+ .rsc_id = MATCHED_STRING,
+ .rsc_id_submatches = submatches,
+ .rsc_id_nmatches = 2,
+
+ // Used when source is instance attributes
+ .rsc_params = NULL,
+
+ // Used when source is meta-attributes
+ .rsc_meta = NULL,
+
+ // Used to get actual value of node attribute
+ .node_attrs = NULL,
+};
+
+static int
+setup(void **state)
+{
+ rule_input.rsc_params = pcmk__strkey_table(free, free);
+ pcmk__insert_dup(rule_input.rsc_params, "foo-param", "bar");
+ pcmk__insert_dup(rule_input.rsc_params, "myparam", "different");
+
+ rule_input.rsc_meta = pcmk__strkey_table(free, free);
+ pcmk__insert_dup(rule_input.rsc_meta, "foo-meta", "bar");
+ pcmk__insert_dup(rule_input.rsc_params, "mymeta", "different");
+
+ rule_input.node_attrs = pcmk__strkey_table(free, free);
+ pcmk__insert_dup(rule_input.node_attrs, "foo", "bar");
+ pcmk__insert_dup(rule_input.node_attrs, "num", "10");
+ pcmk__insert_dup(rule_input.node_attrs, "ver", "3.5.0");
+ pcmk__insert_dup(rule_input.node_attrs, "prefer-north", "100");
+
+ return 0;
+}
+
+static int
+teardown(void **state)
+{
+ g_hash_table_destroy(rule_input.rsc_params);
+ g_hash_table_destroy(rule_input.rsc_meta);
+ g_hash_table_destroy(rule_input.node_attrs);
+ return 0;
+}
+
+/*!
+ * \internal
+ * \brief Run one test, comparing return value
+ *
+ * \param[in] xml_string Node attribute expression XML as string
+ * \param[in] reference_rc Assert that evaluation result equals this
+ */
+static void
+assert_attr_expression(const char *xml_string, int reference_rc)
+{
+ xmlNode *xml = pcmk__xml_parse(xml_string);
+
+ assert_int_equal(pcmk__evaluate_attr_expression(xml, &rule_input),
+ reference_rc);
+ free_xml(xml);
+}
+
+
+/*
+ * Invalid arguments
+ */
+
+#define EXPR_SOURCE_LITERAL_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='bar' " \
+ PCMK_XA_VALUE_SOURCE "='" PCMK_VALUE_LITERAL "' />"
+
+static void
+null_invalid(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(EXPR_SOURCE_LITERAL_PASSES);
+
+ assert_int_equal(pcmk__evaluate_attr_expression(NULL, NULL), EINVAL);
+ assert_int_equal(pcmk__evaluate_attr_expression(xml, NULL), EINVAL);
+ assert_int_equal(pcmk__evaluate_attr_expression(NULL, &rule_input), EINVAL);
+
+ free_xml(xml);
+}
+
+
+/*
+ * Test PCMK_XA_ID
+ */
+
+#define EXPR_ID_MISSING \
+ "<" PCMK_XE_EXPRESSION " " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='bar' />"
+
+static void
+id_missing(void **state)
+{
+ // Currently acceptable
+ assert_attr_expression(EXPR_ID_MISSING, pcmk_rc_ok);
+}
+
+
+/*
+ * Test PCMK_XA_ATTRIBUTE
+ */
+
+#define EXPR_ATTR_MISSING \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='bar' />"
+
+static void
+attr_missing(void **state)
+{
+ assert_attr_expression(EXPR_ATTR_MISSING, pcmk_rc_unpack_error);
+}
+
+#define EXPR_ATTR_SUBMATCH_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='prefer-%1' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_DEFINED "' />"
+
+static void
+attr_with_submatch_passes(void **state)
+{
+ assert_attr_expression(EXPR_ATTR_SUBMATCH_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_ATTR_SUBMATCH_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='undefined-%1' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_DEFINED "' />"
+
+static void
+attr_with_submatch_fails(void **state)
+{
+ assert_attr_expression(EXPR_ATTR_SUBMATCH_FAILS, pcmk_rc_op_unsatisfied);
+}
+
+
+/*
+ * Test PCMK_XA_VALUE_SOURCE
+ */
+
+#define EXPR_SOURCE_MISSING \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_VALUE "='bar' />"
+
+static void
+source_missing(void **state)
+{
+ // Defaults to literal
+ assert_attr_expression(EXPR_SOURCE_MISSING, pcmk_rc_ok);
+}
+
+#define EXPR_SOURCE_INVALID \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='bar' " \
+ PCMK_XA_VALUE_SOURCE "='not-a-source' />"
+
+static void
+source_invalid(void **state)
+{
+ // Currently treated as literal
+ assert_attr_expression(EXPR_SOURCE_INVALID, pcmk_rc_ok);
+}
+
+static void
+source_literal_passes(void **state)
+{
+ assert_attr_expression(EXPR_SOURCE_LITERAL_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_SOURCE_LITERAL_VALUE_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='wrong-value' " \
+ PCMK_XA_VALUE_SOURCE "='" PCMK_VALUE_LITERAL "' />"
+
+static void
+source_literal_value_fails(void **state)
+{
+ assert_attr_expression(EXPR_SOURCE_LITERAL_VALUE_FAILS,
+ pcmk_rc_op_unsatisfied);
+}
+
+#define EXPR_SOURCE_LITERAL_ATTR_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='not-an-attribute' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='bar' " \
+ PCMK_XA_VALUE_SOURCE "='" PCMK_VALUE_LITERAL "' />"
+
+static void
+source_literal_attr_fails(void **state)
+{
+ assert_attr_expression(EXPR_SOURCE_LITERAL_ATTR_FAILS,
+ pcmk_rc_op_unsatisfied);
+}
+
+#define EXPR_SOURCE_PARAM_MISSING \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='not-a-param' " \
+ PCMK_XA_VALUE_SOURCE "='" PCMK_VALUE_PARAM "' />"
+
+static void
+source_params_missing(void **state)
+{
+ assert_attr_expression(EXPR_SOURCE_PARAM_MISSING, pcmk_rc_op_unsatisfied);
+}
+
+#define EXPR_SOURCE_PARAM_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='foo-param' " \
+ PCMK_XA_VALUE_SOURCE "='" PCMK_VALUE_PARAM "' />"
+
+static void
+source_params_passes(void **state)
+{
+ assert_attr_expression(EXPR_SOURCE_PARAM_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_SOURCE_PARAM_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='myparam' " \
+ PCMK_XA_VALUE_SOURCE "='" PCMK_VALUE_PARAM "' />"
+
+static void
+source_params_fails(void **state)
+{
+ assert_attr_expression(EXPR_SOURCE_PARAM_FAILS, pcmk_rc_op_unsatisfied);
+}
+
+#define EXPR_SOURCE_META_MISSING \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='not-a-meta' " \
+ PCMK_XA_VALUE_SOURCE "='" PCMK_VALUE_META "' />"
+
+static void
+source_meta_missing(void **state)
+{
+ assert_attr_expression(EXPR_SOURCE_META_MISSING, pcmk_rc_op_unsatisfied);
+}
+
+#define EXPR_SOURCE_META_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='foo-meta' " \
+ PCMK_XA_VALUE_SOURCE "='" PCMK_VALUE_META "' />"
+
+static void
+source_meta_passes(void **state)
+{
+ assert_attr_expression(EXPR_SOURCE_META_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_SOURCE_META_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='mymeta' " \
+ PCMK_XA_VALUE_SOURCE "='" PCMK_VALUE_META "' />"
+
+static void
+source_meta_fails(void **state)
+{
+ assert_attr_expression(EXPR_SOURCE_META_FAILS, pcmk_rc_op_unsatisfied);
+}
+
+
+/*
+ * Test PCMK_XA_TYPE
+ */
+
+#define EXPR_TYPE_DEFAULT_NUMBER \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_GT "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='2.5' />"
+
+static void
+type_default_number(void **state)
+{
+ // Defaults to number for "gt" if either value contains a decimal point
+ assert_attr_expression(EXPR_TYPE_DEFAULT_NUMBER, pcmk_rc_ok);
+}
+
+#define EXPR_TYPE_DEFAULT_INT \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_GT "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='2' />"
+
+static void
+type_default_int(void **state)
+{
+ // Defaults to integer for "gt" if neither value contains a decimal point
+ assert_attr_expression(EXPR_TYPE_DEFAULT_INT, pcmk_rc_ok);
+}
+
+#define EXPR_TYPE_STRING_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_STRING "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_VALUE "='bar' />"
+
+static void
+type_string_passes(void **state)
+{
+ assert_attr_expression(EXPR_TYPE_STRING_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_TYPE_STRING_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_STRING "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_VALUE "='bat' />"
+
+static void
+type_string_fails(void **state)
+{
+ assert_attr_expression(EXPR_TYPE_STRING_FAILS, pcmk_rc_op_unsatisfied);
+}
+
+#define EXPR_TYPE_INTEGER_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='10' />"
+
+static void
+type_integer_passes(void **state)
+{
+ assert_attr_expression(EXPR_TYPE_INTEGER_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_TYPE_INTEGER_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='11' />"
+
+static void
+type_integer_fails(void **state)
+{
+ assert_attr_expression(EXPR_TYPE_INTEGER_FAILS, pcmk_rc_op_unsatisfied);
+}
+
+#define EXPR_TYPE_INTEGER_TRUNCATION \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='10.5' />"
+
+static void
+type_integer_truncation(void **state)
+{
+ assert_attr_expression(EXPR_TYPE_INTEGER_TRUNCATION, pcmk_rc_ok);
+}
+
+#define EXPR_TYPE_NUMBER_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_NUMBER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='10.0' />"
+
+static void
+type_number_passes(void **state)
+{
+ assert_attr_expression(EXPR_TYPE_NUMBER_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_TYPE_NUMBER_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_NUMBER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='10.1' />"
+
+static void
+type_number_fails(void **state)
+{
+ assert_attr_expression(EXPR_TYPE_NUMBER_FAILS, pcmk_rc_op_unsatisfied);
+}
+
+#define EXPR_TYPE_VERSION_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_VERSION "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_GT "' " \
+ PCMK_XA_ATTRIBUTE "='ver' " \
+ PCMK_XA_VALUE "='3.4.9' />"
+
+static void
+type_version_passes(void **state)
+{
+ assert_attr_expression(EXPR_TYPE_VERSION_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_TYPE_VERSION_EQUALITY \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_VERSION "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_ATTRIBUTE "='ver' " \
+ PCMK_XA_VALUE "='3.5' />"
+
+static void
+type_version_equality(void **state)
+{
+ assert_attr_expression(EXPR_TYPE_VERSION_EQUALITY, pcmk_rc_ok);
+}
+
+#define EXPR_TYPE_VERSION_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_VERSION "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_GTE "' " \
+ PCMK_XA_ATTRIBUTE "='ver' " \
+ PCMK_XA_VALUE "='4.0' />"
+
+static void
+type_version_fails(void **state)
+{
+ assert_attr_expression(EXPR_TYPE_VERSION_FAILS, pcmk_rc_before_range);
+}
+
+/*
+ * Test PCMK_XA_OPERATION
+ */
+
+#define EXPR_OP_MISSING \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_VALUE "='bar' />"
+
+static void
+op_missing(void **state)
+{
+ assert_attr_expression(EXPR_OP_MISSING, pcmk_rc_unpack_error);
+}
+
+#define EXPR_OP_INVALID \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='not-an-operation' " \
+ PCMK_XA_VALUE "='bar' />"
+
+static void
+op_invalid(void **state)
+{
+ assert_attr_expression(EXPR_OP_INVALID, pcmk_rc_unpack_error);
+}
+
+#define EXPR_OP_LT_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_LT "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='20' />"
+
+static void
+op_lt_passes(void **state)
+{
+ assert_attr_expression(EXPR_OP_LT_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_OP_LT_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_LT "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='2' />"
+
+static void
+op_lt_fails(void **state)
+{
+ assert_attr_expression(EXPR_OP_LT_FAILS, pcmk_rc_after_range);
+}
+
+#define EXPR_OP_GT_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_GT "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='2' />"
+
+static void
+op_gt_passes(void **state)
+{
+ assert_attr_expression(EXPR_OP_GT_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_OP_GT_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_GT "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='20' />"
+
+static void
+op_gt_fails(void **state)
+{
+ assert_attr_expression(EXPR_OP_GT_FAILS, pcmk_rc_before_range);
+}
+
+#define EXPR_OP_LTE_LT_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_LTE "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='20' />"
+
+static void
+op_lte_lt_passes(void **state)
+{
+ assert_attr_expression(EXPR_OP_LTE_LT_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_OP_LTE_EQ_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_LTE "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='10' />"
+
+static void
+op_lte_eq_passes(void **state)
+{
+ assert_attr_expression(EXPR_OP_LTE_EQ_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_OP_LTE_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_LTE "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='9' />"
+
+static void
+op_lte_fails(void **state)
+{
+ assert_attr_expression(EXPR_OP_LTE_FAILS, pcmk_rc_after_range);
+}
+
+#define EXPR_OP_GTE_GT_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_GTE "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='1' />"
+
+static void
+op_gte_gt_passes(void **state)
+{
+ assert_attr_expression(EXPR_OP_GTE_GT_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_OP_GTE_EQ_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_GTE "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='10' />"
+
+static void
+op_gte_eq_passes(void **state)
+{
+ assert_attr_expression(EXPR_OP_GTE_EQ_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_OP_GTE_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_GTE "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='11' />"
+
+static void
+op_gte_fails(void **state)
+{
+ assert_attr_expression(EXPR_OP_GTE_FAILS, pcmk_rc_before_range);
+}
+
+// This also tests that string is used if values aren't parseable as numbers
+#define EXPR_OP_EQ_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_NUMBER "' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='bar' " \
+ PCMK_XA_VALUE_SOURCE "='" PCMK_VALUE_LITERAL "' />"
+
+static void
+op_eq_passes(void **state)
+{
+ assert_attr_expression(EXPR_OP_EQ_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_OP_EQ_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='bar' />"
+
+static void
+op_eq_fails(void **state)
+{
+ assert_attr_expression(EXPR_OP_EQ_FAILS, pcmk_rc_op_unsatisfied);
+}
+
+#define EXPR_OP_NE_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_STRING "' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_NE "' " \
+ PCMK_XA_VALUE "='bat' " \
+ PCMK_XA_VALUE_SOURCE "='" PCMK_VALUE_LITERAL "' />"
+
+static void
+op_ne_passes(void **state)
+{
+ assert_attr_expression(EXPR_OP_NE_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_OP_NE_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_TYPE "='" PCMK_VALUE_INTEGER "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_NE "' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_VALUE "='10' />"
+
+static void
+op_ne_fails(void **state)
+{
+ assert_attr_expression(EXPR_OP_NE_FAILS, pcmk_rc_op_unsatisfied);
+}
+
+#define EXPR_OP_DEFINED_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_DEFINED "' />"
+
+static void
+op_defined_passes(void **state)
+{
+ assert_attr_expression(EXPR_OP_DEFINED_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_OP_DEFINED_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='boo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_DEFINED "' />"
+
+static void
+op_defined_fails(void **state)
+{
+ assert_attr_expression(EXPR_OP_DEFINED_FAILS, pcmk_rc_op_unsatisfied);
+}
+
+#define EXPR_OP_DEFINED_WITH_VALUE \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_VALUE "='bar' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_DEFINED "' />"
+
+static void
+op_defined_with_value(void **state)
+{
+ // Ill-formed but currently accepted
+ assert_attr_expression(EXPR_OP_DEFINED_WITH_VALUE, pcmk_rc_ok);
+}
+
+#define EXPR_OP_UNDEFINED_PASSES \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='boo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_NOT_DEFINED "' />"
+
+static void
+op_undefined_passes(void **state)
+{
+ assert_attr_expression(EXPR_OP_UNDEFINED_PASSES, pcmk_rc_ok);
+}
+
+#define EXPR_OP_UNDEFINED_FAILS \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_NOT_DEFINED "' />"
+
+static void
+op_undefined_fails(void **state)
+{
+ assert_attr_expression(EXPR_OP_DEFINED_FAILS, pcmk_rc_op_unsatisfied);
+}
+
+
+/*
+ * Test PCMK_XA_VALUE
+ */
+
+#define EXPR_VALUE_MISSING_DEFINED_OK \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='num' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_DEFINED "' />"
+
+static void
+value_missing_defined_ok(void **state)
+{
+ assert_attr_expression(EXPR_VALUE_MISSING_DEFINED_OK, pcmk_rc_ok);
+}
+
+#define EXPR_VALUE_MISSING_EQ_OK \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='not-an-attr' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' />"
+
+static void
+value_missing_eq_ok(void **state)
+{
+ // Currently treated as NULL reference value
+ assert_attr_expression(EXPR_VALUE_MISSING_EQ_OK, pcmk_rc_ok);
+}
+
+
+#define expr_test(f) cmocka_unit_test_setup_teardown(f, setup, teardown)
+
+PCMK__UNIT_TEST(pcmk__xml_test_setup_group, NULL,
+ cmocka_unit_test(null_invalid),
+ expr_test(id_missing),
+ expr_test(attr_missing),
+ expr_test(attr_with_submatch_passes),
+ expr_test(attr_with_submatch_fails),
+ expr_test(source_missing),
+ expr_test(source_invalid),
+ expr_test(source_literal_passes),
+ expr_test(source_literal_value_fails),
+ expr_test(source_literal_attr_fails),
+ expr_test(source_params_missing),
+ expr_test(source_params_passes),
+ expr_test(source_params_fails),
+ expr_test(source_meta_missing),
+ expr_test(source_meta_passes),
+ expr_test(source_meta_fails),
+ expr_test(type_default_number),
+ expr_test(type_default_int),
+ expr_test(type_string_passes),
+ expr_test(type_string_fails),
+ expr_test(type_integer_passes),
+ expr_test(type_integer_fails),
+ expr_test(type_integer_truncation),
+ expr_test(type_number_passes),
+ expr_test(type_number_fails),
+ expr_test(type_version_passes),
+ expr_test(type_version_equality),
+ expr_test(type_version_fails),
+ expr_test(op_missing),
+ expr_test(op_invalid),
+ expr_test(op_lt_passes),
+ expr_test(op_lt_fails),
+ expr_test(op_gt_passes),
+ expr_test(op_gt_fails),
+ expr_test(op_lte_lt_passes),
+ expr_test(op_lte_eq_passes),
+ expr_test(op_lte_fails),
+ expr_test(op_gte_gt_passes),
+ expr_test(op_gte_eq_passes),
+ expr_test(op_gte_fails),
+ expr_test(op_eq_passes),
+ expr_test(op_eq_fails),
+ expr_test(op_ne_passes),
+ expr_test(op_ne_fails),
+ expr_test(op_defined_passes),
+ expr_test(op_defined_fails),
+ expr_test(op_defined_with_value),
+ expr_test(op_undefined_passes),
+ expr_test(op_undefined_fails),
+ expr_test(value_missing_defined_ok),
+ expr_test(value_missing_eq_ok))
diff --git a/lib/common/tests/rules/pcmk__evaluate_condition_test.c b/lib/common/tests/rules/pcmk__evaluate_condition_test.c
new file mode 100644
index 0000000..bcb13a0
--- /dev/null
+++ b/lib/common/tests/rules/pcmk__evaluate_condition_test.c
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <stdio.h>
+#include <glib.h>
+
+#include <crm/common/xml.h>
+#include <crm/common/rules_internal.h>
+#include <crm/common/unittest_internal.h>
+
+/*
+ * Shared data
+ */
+
+static pcmk_rule_input_t rule_input = {
+ .rsc_standard = PCMK_RESOURCE_CLASS_OCF,
+ .rsc_provider = "heartbeat",
+ .rsc_agent = "IPaddr2",
+ .op_name = PCMK_ACTION_MONITOR,
+ .op_interval_ms = 10000,
+};
+
+
+/*
+ * Test invalid arguments
+ */
+
+#define EXPR_ATTRIBUTE \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='foo' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='bar' />"
+
+static void
+null_invalid(void **state)
+{
+ xmlNode *xml = NULL;
+ crm_time_t *next_change = crm_time_new_undefined();
+
+ assert_int_equal(pcmk__evaluate_condition(NULL, NULL, next_change), EINVAL);
+
+ xml = pcmk__xml_parse(EXPR_ATTRIBUTE);
+ assert_int_equal(pcmk__evaluate_condition(xml, NULL, next_change), EINVAL);
+ free_xml(xml);
+
+ assert_int_equal(pcmk__evaluate_condition(NULL, &rule_input, next_change),
+ EINVAL);
+
+ crm_time_free(next_change);
+}
+
+
+#define EXPR_INVALID "<not_an_expression " PCMK_XA_ID "='e' />"
+
+static void
+invalid_expression(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(EXPR_INVALID);
+ crm_time_t *next_change = crm_time_new_undefined();
+
+ assert_int_equal(pcmk__evaluate_condition(xml, &rule_input, next_change),
+ pcmk_rc_unpack_error);
+
+ crm_time_free(next_change);
+ free_xml(xml);
+}
+
+
+/* Each expression type function already has unit tests, so we just need to test
+ * that they are called correctly (essentially, one of each one's own tests).
+ */
+
+static void
+attribute_expression(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(EXPR_ATTRIBUTE);
+
+ rule_input.node_attrs = pcmk__strkey_table(free, free);
+ pcmk__insert_dup(rule_input.node_attrs, "foo", "bar");
+
+ assert_int_equal(pcmk__evaluate_condition(xml, &rule_input, NULL),
+ pcmk_rc_ok);
+
+ g_hash_table_destroy(rule_input.node_attrs);
+ rule_input.node_attrs = NULL;
+ free_xml(xml);
+}
+
+#define EXPR_LOCATION \
+ "<" PCMK_XE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_ATTRIBUTE "='" CRM_ATTR_UNAME "' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_EQ "' " \
+ PCMK_XA_VALUE "='node1' />"
+
+static void
+location_expression(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(EXPR_LOCATION);
+
+ rule_input.node_attrs = pcmk__strkey_table(free, free);
+ pcmk__insert_dup(rule_input.node_attrs, CRM_ATTR_UNAME, "node1");
+
+ assert_int_equal(pcmk__evaluate_condition(xml, &rule_input, NULL),
+ pcmk_rc_ok);
+
+ g_hash_table_destroy(rule_input.node_attrs);
+ rule_input.node_attrs = NULL;
+ free_xml(xml);
+}
+
+#define EXPR_DATE \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' " \
+ PCMK_XA_START "='2024-02-01 12:00:00' " \
+ PCMK_XA_END "='2024-02-01 15:00:00' />"
+
+static void
+date_expression(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(EXPR_DATE);
+ crm_time_t *now = crm_time_new("2024-02-01 11:59:59");
+ crm_time_t *next_change = crm_time_new("2024-02-01 14:00:00");
+ crm_time_t *reference = crm_time_new("2024-02-01 12:00:00");
+
+ rule_input.now = now;
+ assert_int_equal(pcmk__evaluate_condition(xml, &rule_input, next_change),
+ pcmk_rc_before_range);
+ assert_int_equal(crm_time_compare(next_change, reference), 0);
+ rule_input.now = NULL;
+
+ crm_time_free(reference);
+ crm_time_free(next_change);
+ crm_time_free(now);
+}
+
+#define EXPR_RESOURCE \
+ "<" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_CLASS "='" PCMK_RESOURCE_CLASS_OCF "' " \
+ PCMK_XA_TYPE "='IPaddr2' />"
+
+static void
+resource_expression(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(EXPR_RESOURCE);
+
+ assert_int_equal(pcmk__evaluate_condition(xml, &rule_input, NULL),
+ pcmk_rc_ok);
+ free_xml(xml);
+}
+
+#define EXPR_OP \
+ "<" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' />"
+
+static void
+op_expression(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(EXPR_OP);
+
+ assert_int_equal(pcmk__evaluate_condition(xml, &rule_input, NULL),
+ pcmk_rc_ok);
+ free_xml(xml);
+}
+
+#define EXPR_SUBRULE \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' " \
+ " <" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' /> />"
+
+static void
+subrule(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(EXPR_SUBRULE);
+ assert_int_equal(pcmk__evaluate_condition(xml, &rule_input, NULL),
+ pcmk_rc_ok);
+ free_xml(xml);
+}
+
+PCMK__UNIT_TEST(pcmk__xml_test_setup_group, NULL,
+ cmocka_unit_test(null_invalid),
+ cmocka_unit_test(invalid_expression),
+ cmocka_unit_test(attribute_expression),
+ cmocka_unit_test(location_expression),
+ cmocka_unit_test(date_expression),
+ cmocka_unit_test(resource_expression),
+ cmocka_unit_test(op_expression),
+ cmocka_unit_test(subrule))
diff --git a/lib/common/tests/rules/pcmk__evaluate_date_expression_test.c b/lib/common/tests/rules/pcmk__evaluate_date_expression_test.c
new file mode 100644
index 0000000..df8dcbf
--- /dev/null
+++ b/lib/common/tests/rules/pcmk__evaluate_date_expression_test.c
@@ -0,0 +1,684 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <stdio.h>
+#include <glib.h>
+
+#include <crm/common/xml.h>
+#include <crm/common/rules_internal.h>
+#include <crm/common/unittest_internal.h>
+#include "crmcommon_private.h"
+
+/*!
+ * \internal
+ * \brief Run one test, comparing return value and output argument
+ *
+ * \param[in] xml Date expression XML
+ * \param[in] now_s Time to evaluate expression with (as string)
+ * \param[in] next_change_s If this and \p reference_s are not NULL, initialize
+ * next change time with this time (as string),
+ * and assert that its value after evaluation is the
+ * reference
+ * \param[in] reference_s If not NULL, time (as string) that next change
+ * should be after expression evaluation
+ * \param[in] reference_rc Assert that evaluation result equals this
+ */
+static void
+assert_date_expression(const xmlNode *xml, const char *now_s,
+ const char *next_change_s, const char *reference_s,
+ int reference_rc)
+{
+ crm_time_t *now = NULL;
+ crm_time_t *next_change = NULL;
+ bool check_next_change = (next_change_s != NULL) && (reference_s != NULL);
+
+ if (check_next_change) {
+ next_change = crm_time_new(next_change_s);
+ }
+
+ now = crm_time_new(now_s);
+ assert_int_equal(pcmk__evaluate_date_expression(xml, now, next_change),
+ reference_rc);
+ crm_time_free(now);
+
+ if (check_next_change) {
+ crm_time_t *reference = crm_time_new(reference_s);
+
+ assert_int_equal(crm_time_compare(next_change, reference), 0);
+ crm_time_free(reference);
+ crm_time_free(next_change);
+ }
+}
+
+#define EXPR_LT_VALID \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_LT "' " \
+ PCMK_XA_END "='2024-02-01 15:00:00' />"
+
+static void
+null_invalid(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_LT_VALID);
+ crm_time_t *t = crm_time_new("2024-02-01");
+
+ assert_int_equal(pcmk__evaluate_date_expression(NULL, NULL, NULL), EINVAL);
+ assert_int_equal(pcmk__evaluate_date_expression(xml, NULL, NULL), EINVAL);
+ assert_int_equal(pcmk__evaluate_date_expression(NULL, t, NULL), EINVAL);
+
+ crm_time_free(t);
+ free_xml(xml);
+}
+
+static void
+null_next_change_ok(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_LT_VALID);
+
+ assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_within_range);
+ free_xml(xml);
+}
+
+#define EXPR_ID_MISSING \
+ "<" PCMK_XE_DATE_EXPRESSION " " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_LT "' " \
+ PCMK_XA_END "='2024-02-01 15:00:00' />"
+
+static void
+id_missing(void **state)
+{
+ // Currently acceptable
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_ID_MISSING);
+
+ assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_within_range);
+ free_xml(xml);
+}
+
+#define EXPR_OP_INVALID \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='not-a-choice' />"
+
+static void
+op_invalid(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_OP_INVALID);
+
+ assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
+ free_xml(xml);
+}
+
+#define EXPR_LT_MISSING_END \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_LT "' />"
+
+static void
+lt_missing_end(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_LT_MISSING_END);
+
+ assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
+ free_xml(xml);
+}
+
+#define EXPR_LT_INVALID_END \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_LT "' " \
+ PCMK_XA_END "='not-a-datetime' />"
+
+static void
+lt_invalid_end(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_LT_INVALID_END);
+
+ assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
+ free_xml(xml);
+}
+
+static void
+lt_valid(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_LT_VALID);
+
+ // Now and next change are both before end
+ assert_date_expression(xml, "2023-01-01 05:00:00", "2024-02-01 10:00:00",
+ "2024-02-01 10:00:00", pcmk_rc_within_range);
+
+ // Now is before end, next change is after end
+ assert_date_expression(xml, "2024-02-01 14:59:59", "2024-02-01 18:00:00",
+ "2024-02-01 15:00:00", pcmk_rc_within_range);
+
+ // Now is equal to end, next change is after end
+ assert_date_expression(xml, "2024-02-01 15:00:00", "2024-02-01 20:00:00",
+ "2024-02-01 20:00:00", pcmk_rc_after_range);
+
+ // Now and next change are both after end
+ assert_date_expression(xml, "2024-03-01 12:00:00", "2024-02-01 20:00:00",
+ "2024-02-01 20:00:00", pcmk_rc_after_range);
+
+ free_xml(xml);
+}
+
+#define EXPR_GT_MISSING_START \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_GT "' />"
+
+static void
+gt_missing_start(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_GT_MISSING_START);
+
+ assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
+ free_xml(xml);
+}
+
+#define EXPR_GT_INVALID_START \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_GT "' " \
+ PCMK_XA_START "='not-a-datetime' />"
+
+static void
+gt_invalid_start(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_GT_INVALID_START);
+
+ assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
+ free_xml(xml);
+}
+
+#define EXPR_GT_VALID \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_GT "' " \
+ PCMK_XA_START "='2024-02-01 12:00:00' />"
+
+static void
+gt_valid(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_GT_VALID);
+
+ // Now and next change are both before start
+ assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
+ "2024-01-01 11:00:00", pcmk_rc_before_range);
+
+ // Now is before start, next change is after start
+ assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 18:00:00",
+ "2024-02-01 12:00:01", pcmk_rc_before_range);
+
+ // Now is equal to start, next change is after start
+ assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 18:00:00",
+ "2024-02-01 12:00:01", pcmk_rc_before_range);
+
+ // Now is one second after start, next change is after start
+ assert_date_expression(xml, "2024-02-01 12:00:01", "2024-02-01 18:00:00",
+ "2024-02-01 18:00:00", pcmk_rc_within_range);
+
+ // t is after start, next change is after start
+ assert_date_expression(xml, "2024-03-01 05:03:11", "2024-04-04 04:04:04",
+ "2024-04-04 04:04:04", pcmk_rc_within_range);
+
+ free_xml(xml);
+}
+
+#define EXPR_RANGE_MISSING \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' />"
+
+static void
+range_missing(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_MISSING);
+ crm_time_t *t = crm_time_new("2024-01-01");
+
+ assert_int_equal(pcmk__evaluate_date_expression(xml, t, NULL),
+ pcmk_rc_undetermined);
+
+ crm_time_free(t);
+ free_xml(xml);
+}
+
+#define EXPR_RANGE_INVALID_START_INVALID_END \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' " \
+ PCMK_XA_START "='not-a-date' " \
+ PCMK_XA_END "='not-a-date' />"
+
+static void
+range_invalid_start_invalid_end(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_INVALID_START_INVALID_END);
+
+ assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
+ free_xml(xml);
+}
+
+#define EXPR_RANGE_INVALID_START_ONLY \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' " \
+ PCMK_XA_START "='not-a-date' />"
+
+static void
+range_invalid_start_only(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_INVALID_START_ONLY);
+
+ assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
+ free_xml(xml);
+}
+
+#define EXPR_RANGE_VALID_START_ONLY \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' " \
+ PCMK_XA_START "='2024-02-01 12:00:00' />"
+
+static void
+range_valid_start_only(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_VALID_START_ONLY);
+
+ // Now and next change are before start
+ assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
+ "2024-01-01 11:00:00", pcmk_rc_before_range);
+
+ // Now is before start, next change is after start
+ assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 18:00:00",
+ "2024-02-01 12:00:00", pcmk_rc_before_range);
+
+ // Now is equal to start, next change is after start
+ assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 18:00:00",
+ "2024-02-01 18:00:00", pcmk_rc_within_range);
+
+ // Now and next change are after start
+ assert_date_expression(xml, "2024-03-01 05:03:11", "2024-04-04 04:04:04",
+ "2024-04-04 04:04:04", pcmk_rc_within_range);
+
+ free_xml(xml);
+}
+
+#define EXPR_RANGE_INVALID_END_ONLY \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' " \
+ PCMK_XA_END "='not-a-date' />"
+
+static void
+range_invalid_end_only(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_INVALID_END_ONLY);
+
+ assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
+ free_xml(xml);
+}
+
+#define EXPR_RANGE_VALID_END_ONLY \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' " \
+ PCMK_XA_END "='2024-02-01 15:00:00' />"
+
+static void
+range_valid_end_only(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_VALID_END_ONLY);
+
+ // Now and next change are before end
+ assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
+ "2024-01-01 11:00:00", pcmk_rc_within_range);
+
+ // Now is before end, next change is after end
+ assert_date_expression(xml, "2024-02-01 14:59:59", "2024-02-01 18:00:00",
+ "2024-02-01 15:00:01", pcmk_rc_within_range);
+
+ // Now is equal to end, next change is after end
+ assert_date_expression(xml, "2024-02-01 15:00:00", "2024-02-01 18:00:00",
+ "2024-02-01 15:00:01", pcmk_rc_within_range);
+
+ // Now and next change are after end
+ assert_date_expression(xml, "2024-02-01 15:00:01", "2024-04-04 04:04:04",
+ "2024-04-04 04:04:04", pcmk_rc_after_range);
+
+ free_xml(xml);
+}
+
+#define EXPR_RANGE_VALID_START_INVALID_END \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' " \
+ PCMK_XA_START "='2024-02-01 12:00:00' " \
+ PCMK_XA_END "='not-a-date' />"
+
+static void
+range_valid_start_invalid_end(void **state)
+{
+ // Currently treated same as start without end
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_VALID_START_INVALID_END);
+
+ // Now and next change are before start
+ assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
+ "2024-01-01 11:00:00", pcmk_rc_before_range);
+
+ // Now is before start, next change is after start
+ assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 18:00:00",
+ "2024-02-01 12:00:00", pcmk_rc_before_range);
+
+ // Now is equal to start, next change is after start
+ assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 18:00:00",
+ "2024-02-01 18:00:00", pcmk_rc_within_range);
+
+ // Now and next change are after start
+ assert_date_expression(xml, "2024-03-01 05:03:11", "2024-04-04 04:04:04",
+ "2024-04-04 04:04:04", pcmk_rc_within_range);
+
+ free_xml(xml);
+}
+
+#define EXPR_RANGE_INVALID_START_VALID_END \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' " \
+ PCMK_XA_START "='not-a-date' " \
+ PCMK_XA_END "='2024-02-01 15:00:00' />"
+
+static void
+range_invalid_start_valid_end(void **state)
+{
+ // Currently treated same as end without start
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_INVALID_START_VALID_END);
+
+ // Now and next change are before end
+ assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
+ "2024-01-01 11:00:00", pcmk_rc_within_range);
+
+ // Now is before end, next change is after end
+ assert_date_expression(xml, "2024-02-01 14:59:59", "2024-02-01 18:00:00",
+ "2024-02-01 15:00:01", pcmk_rc_within_range);
+
+ // Now is equal to end, next change is after end
+ assert_date_expression(xml, "2024-02-01 15:00:00", "2024-02-01 18:00:00",
+ "2024-02-01 15:00:01", pcmk_rc_within_range);
+
+ // Now and next change are after end
+ assert_date_expression(xml, "2024-02-01 15:00:01", "2024-04-04 04:04:04",
+ "2024-04-04 04:04:04", pcmk_rc_after_range);
+
+ free_xml(xml);
+}
+
+#define EXPR_RANGE_VALID_START_VALID_END \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' " \
+ PCMK_XA_START "='2024-02-01 12:00:00' " \
+ PCMK_XA_END "='2024-02-01 15:00:00' />"
+
+static void
+range_valid_start_valid_end(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_VALID_START_VALID_END);
+
+ // Now and next change are before start
+ assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
+ "2024-01-01 11:00:00", pcmk_rc_before_range);
+
+ // Now is before start, next change is between start and end
+ assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 14:00:00",
+ "2024-02-01 12:00:00", pcmk_rc_before_range);
+
+ // Now is equal to start, next change is between start and end
+ assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 14:30:00",
+ "2024-02-01 14:30:00", pcmk_rc_within_range);
+
+ // Now is between start and end, next change is after end
+ assert_date_expression(xml, "2024-02-01 14:03:11", "2024-04-04 04:04:04",
+ "2024-02-01 15:00:01", pcmk_rc_within_range);
+
+ // Now is equal to end, next change is after end
+ assert_date_expression(xml, "2024-02-01 15:00:00", "2028-04-04 04:04:04",
+ "2024-02-01 15:00:01", pcmk_rc_within_range);
+
+ // Now and next change are after end
+ assert_date_expression(xml, "2024-02-01 15:00:01", "2028-04-04 04:04:04",
+ "2028-04-04 04:04:04", pcmk_rc_after_range);
+
+ free_xml(xml);
+}
+
+#define EXPR_RANGE_VALID_START_INVALID_DURATION \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' " \
+ PCMK_XA_START "='2024-02-01 12:00:00'>" \
+ "<" PCMK_XE_DURATION " " PCMK_XA_ID "='d' " \
+ PCMK_XA_HOURS "='not-a-number' />" \
+ "</" PCMK_XE_DATE_EXPRESSION ">"
+
+static void
+range_valid_start_invalid_duration(void **state)
+{
+ // Currently treated same as end equals start
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_VALID_START_INVALID_DURATION);
+
+ // Now and next change are before start
+ assert_date_expression(xml, "2024-02-01 04:30:05", "2024-01-01 11:00:00",
+ "2024-01-01 11:00:00", pcmk_rc_before_range);
+
+ // Now is before start, next change is after start
+ assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 18:00:00",
+ "2024-02-01 12:00:00", pcmk_rc_before_range);
+
+ // Now is equal to start, next change is after start
+ assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 14:30:00",
+ "2024-02-01 12:00:01", pcmk_rc_within_range);
+
+ // Now and next change are after start
+ assert_date_expression(xml, "2024-02-01 12:00:01", "2024-02-01 14:30:00",
+ "2024-02-01 14:30:00", pcmk_rc_after_range);
+
+ free_xml(xml);
+}
+
+#define EXPR_RANGE_VALID_START_VALID_DURATION \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' " \
+ PCMK_XA_START "='2024-02-01 12:00:00'>" \
+ "<" PCMK_XE_DURATION " " PCMK_XA_ID "='d' " \
+ PCMK_XA_HOURS "='3' />" \
+ "</" PCMK_XE_DATE_EXPRESSION ">"
+
+static void
+range_valid_start_valid_duration(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_VALID_START_VALID_DURATION);
+
+ // Now and next change are before start
+ assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
+ "2024-01-01 11:00:00", pcmk_rc_before_range);
+
+ // Now is before start, next change is between start and end
+ assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 14:00:00",
+ "2024-02-01 12:00:00", pcmk_rc_before_range);
+
+ // Now is equal to start, next change is between start and end
+ assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 14:30:00",
+ "2024-02-01 14:30:00", pcmk_rc_within_range);
+
+ // Now is between start and end, next change is after end
+ assert_date_expression(xml, "2024-02-01 14:03:11", "2024-04-04 04:04:04",
+ "2024-02-01 15:00:01", pcmk_rc_within_range);
+
+ // Now is equal to end, next change is after end
+ assert_date_expression(xml, "2024-02-01 15:00:00", "2028-04-04 04:04:04",
+ "2024-02-01 15:00:01", pcmk_rc_within_range);
+
+ // Now and next change are after end
+ assert_date_expression(xml, "2024-02-01 15:00:01", "2028-04-04 04:04:04",
+ "2028-04-04 04:04:04", pcmk_rc_after_range);
+
+ free_xml(xml);
+}
+
+#define EXPR_RANGE_VALID_START_DURATION_MISSING_ID \
+ "<" PCMK_XE_DATE_EXPRESSION " " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' " \
+ PCMK_XA_START "='2024-02-01 12:00:00'>" \
+ "<" PCMK_XE_DURATION " " PCMK_XA_ID "='d' " \
+ PCMK_XA_HOURS "='3' />" \
+ "</" PCMK_XE_DATE_EXPRESSION ">"
+
+static void
+range_valid_start_duration_missing_id(void **state)
+{
+ // Currently acceptable
+ xmlNodePtr xml = NULL;
+
+ xml = pcmk__xml_parse(EXPR_RANGE_VALID_START_DURATION_MISSING_ID);
+
+ // Now and next change are before start
+ assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
+ "2024-01-01 11:00:00", pcmk_rc_before_range);
+
+ // Now is before start, next change is between start and end
+ assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 14:00:00",
+ "2024-02-01 12:00:00", pcmk_rc_before_range);
+
+ // Now is equal to start, next change is between start and end
+ assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 14:30:00",
+ "2024-02-01 14:30:00", pcmk_rc_within_range);
+
+ // Now is between start and end, next change is after end
+ assert_date_expression(xml, "2024-02-01 14:03:11", "2024-04-04 04:04:04",
+ "2024-02-01 15:00:01", pcmk_rc_within_range);
+
+ // Now is equal to end, next change is after end
+ assert_date_expression(xml, "2024-02-01 15:00:00", "2028-04-04 04:04:04",
+ "2024-02-01 15:00:01", pcmk_rc_within_range);
+
+ // Now and next change are after end
+ assert_date_expression(xml, "2024-02-01 15:00:01", "2028-04-04 04:04:04",
+ "2028-04-04 04:04:04", pcmk_rc_after_range);
+
+ free_xml(xml);
+}
+
+#define EXPR_SPEC_MISSING \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_DATE_SPEC "' />"
+
+static void
+spec_missing(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_SPEC_MISSING);
+
+ assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
+ free_xml(xml);
+}
+
+#define EXPR_SPEC_INVALID \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_DATE_SPEC "'>" \
+ "<" PCMK_XE_DATE_SPEC " " PCMK_XA_ID "='s' " \
+ PCMK_XA_MONTHS "='not-a-number'/>" \
+ "</" PCMK_XE_DATE_EXPRESSION ">"
+
+static void
+spec_invalid(void **state)
+{
+ // Currently treated as date_spec with no ranges (which passes)
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_SPEC_INVALID);
+
+ assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_ok);
+ free_xml(xml);
+}
+
+#define EXPR_SPEC_VALID \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_DATE_SPEC "'>" \
+ "<" PCMK_XE_DATE_SPEC " " PCMK_XA_ID "='s' " \
+ PCMK_XA_MONTHS "='2'/>" \
+ "</" PCMK_XE_DATE_EXPRESSION ">"
+
+static void
+spec_valid(void **state)
+{
+ // date_spec does not currently support next_change
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_SPEC_VALID);
+
+ // Now is just before spec start
+ assert_date_expression(xml, "2024-01-01 23:59:59", NULL, NULL,
+ pcmk_rc_before_range);
+
+ // Now matches spec start
+ assert_date_expression(xml, "2024-02-01 00:00:00", NULL, NULL, pcmk_rc_ok);
+
+ // Now is within spec range
+ assert_date_expression(xml, "2024-02-22 22:22:22", NULL, NULL, pcmk_rc_ok);
+
+ // Now matches spec end
+ assert_date_expression(xml, "2024-02-29 23:59:59", NULL, NULL, pcmk_rc_ok);
+
+ // Now is just past spec end
+ assert_date_expression(xml, "2024-03-01 00:00:00", NULL, NULL,
+ pcmk_rc_after_range);
+
+ free_xml(xml);
+}
+
+#define EXPR_SPEC_MISSING_ID \
+ "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_OPERATION "='" PCMK_VALUE_DATE_SPEC "'>" \
+ "<" PCMK_XE_DATE_SPEC " " \
+ PCMK_XA_MONTHS "='2'/>" \
+ "</" PCMK_XE_DATE_EXPRESSION ">"
+
+static void
+spec_missing_id(void **state)
+{
+ // Currently acceptable; date_spec does not currently support next_change
+ xmlNodePtr xml = pcmk__xml_parse(EXPR_SPEC_MISSING_ID);
+
+ // Now is just before spec start
+ assert_date_expression(xml, "2024-01-01 23:59:59", NULL, NULL,
+ pcmk_rc_before_range);
+
+ // Now matches spec start
+ assert_date_expression(xml, "2024-02-01 00:00:00", NULL, NULL, pcmk_rc_ok);
+
+ // Now is within spec range
+ assert_date_expression(xml, "2024-02-22 22:22:22", NULL, NULL, pcmk_rc_ok);
+
+ // Now matches spec end
+ assert_date_expression(xml, "2024-02-29 23:59:59", NULL, NULL, pcmk_rc_ok);
+
+ // Now is just past spec end
+ assert_date_expression(xml, "2024-03-01 00:00:00", NULL, NULL,
+ pcmk_rc_after_range);
+
+ free_xml(xml);
+}
+
+PCMK__UNIT_TEST(pcmk__xml_test_setup_group, NULL,
+ cmocka_unit_test(null_invalid),
+ cmocka_unit_test(null_next_change_ok),
+ cmocka_unit_test(id_missing),
+ cmocka_unit_test(op_invalid),
+ cmocka_unit_test(lt_missing_end),
+ cmocka_unit_test(lt_invalid_end),
+ cmocka_unit_test(lt_valid),
+ cmocka_unit_test(gt_missing_start),
+ cmocka_unit_test(gt_invalid_start),
+ cmocka_unit_test(gt_valid),
+ cmocka_unit_test(range_missing),
+ cmocka_unit_test(range_invalid_start_invalid_end),
+ cmocka_unit_test(range_invalid_start_only),
+ cmocka_unit_test(range_valid_start_only),
+ cmocka_unit_test(range_invalid_end_only),
+ cmocka_unit_test(range_valid_end_only),
+ cmocka_unit_test(range_valid_start_invalid_end),
+ cmocka_unit_test(range_invalid_start_valid_end),
+ cmocka_unit_test(range_valid_start_valid_end),
+ cmocka_unit_test(range_valid_start_invalid_duration),
+ cmocka_unit_test(range_valid_start_valid_duration),
+ cmocka_unit_test(range_valid_start_duration_missing_id),
+ cmocka_unit_test(spec_missing),
+ cmocka_unit_test(spec_invalid),
+ cmocka_unit_test(spec_valid),
+ cmocka_unit_test(spec_missing_id))
diff --git a/lib/common/tests/rules/pcmk__evaluate_date_spec_test.c b/lib/common/tests/rules/pcmk__evaluate_date_spec_test.c
new file mode 100644
index 0000000..6048adf
--- /dev/null
+++ b/lib/common/tests/rules/pcmk__evaluate_date_spec_test.c
@@ -0,0 +1,231 @@
+/*
+ * Copyright 2020-2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <errno.h>
+#include <glib.h>
+
+#include <crm/common/xml.h>
+#include <crm/common/rules_internal.h>
+#include <crm/common/unittest_internal.h>
+#include "crmcommon_private.h"
+
+static void
+run_one_test(const char *t, const char *x, int expected)
+{
+ crm_time_t *tm = crm_time_new(t);
+ xmlNodePtr xml = pcmk__xml_parse(x);
+
+ assert_int_equal(pcmk__evaluate_date_spec(xml, tm), expected);
+
+ crm_time_free(tm);
+ free_xml(xml);
+}
+
+static void
+null_invalid(void **state)
+{
+ xmlNodePtr xml = pcmk__xml_parse("<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARS "='2019'/>");
+ crm_time_t *tm = crm_time_new(NULL);
+
+ assert_int_equal(pcmk__evaluate_date_spec(NULL, NULL), EINVAL);
+ assert_int_equal(pcmk__evaluate_date_spec(xml, NULL), EINVAL);
+ assert_int_equal(pcmk__evaluate_date_spec(NULL, tm), EINVAL);
+
+ crm_time_free(tm);
+ free_xml(xml);
+}
+
+static void
+spec_id_missing(void **state)
+{
+ // Currently acceptable
+ run_one_test("2020-01-01", "<date_spec years='2020'/>", pcmk_rc_ok);
+}
+
+static void
+invalid_range(void **state)
+{
+ // Currently acceptable
+ run_one_test("2020-01-01", "<date_spec years='not-a-year' months='1'/>",
+ pcmk_rc_ok);
+}
+
+static void
+time_satisfies_year_spec(void **state)
+{
+ run_one_test("2020-01-01",
+ "<date_spec " PCMK_XA_ID "='spec' years='2020'/>",
+ pcmk_rc_ok);
+}
+
+static void
+time_after_year_spec(void **state)
+{
+ run_one_test("2020-01-01",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARS "='2019'/>",
+ pcmk_rc_after_range);
+}
+
+static void
+time_satisfies_year_range(void **state)
+{
+ run_one_test("2020-01-01",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARS "='2010-2030'/>",
+ pcmk_rc_ok);
+}
+
+static void
+time_before_year_range(void **state)
+{
+ run_one_test("2000-01-01",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARS "='2010-2030'/>",
+ pcmk_rc_before_range);
+}
+
+static void
+time_after_year_range(void **state)
+{
+ run_one_test("2020-01-01",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARS "='2010-2015'/>",
+ pcmk_rc_after_range);
+}
+
+static void
+range_without_start_year_passes(void **state)
+{
+ run_one_test("2010-01-01",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARS "='-2020'/>",
+ pcmk_rc_ok);
+}
+
+static void
+range_without_end_year_passes(void **state)
+{
+ run_one_test("2010-01-01",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARS "='2000-'/>",
+ pcmk_rc_ok);
+ run_one_test("2000-10-01",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARS "='2000-'/>",
+ pcmk_rc_ok);
+}
+
+static void
+yeardays_satisfies(void **state)
+{
+ run_one_test("2020-01-30",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARDAYS "='30'/>",
+ pcmk_rc_ok);
+}
+
+static void
+time_after_yeardays_spec(void **state)
+{
+ run_one_test("2020-02-15",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARDAYS "='40'/>",
+ pcmk_rc_after_range);
+}
+
+static void
+yeardays_feb_29_satisfies(void **state)
+{
+ run_one_test("2016-02-29",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARDAYS "='60'/>",
+ pcmk_rc_ok);
+}
+
+static void
+exact_ymd_satisfies(void **state)
+{
+ run_one_test("2001-12-31",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARS "='2001' "
+ PCMK_XA_MONTHS "='12' "
+ PCMK_XA_MONTHDAYS "='31'/>",
+ pcmk_rc_ok);
+}
+
+static void
+range_in_month_satisfies(void **state)
+{
+ run_one_test("2001-06-10",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARS "='2001' "
+ PCMK_XA_MONTHS "='6' "
+ PCMK_XA_MONTHDAYS "='1-10'/>",
+ pcmk_rc_ok);
+}
+
+static void
+exact_ymd_after_range(void **state)
+{
+ run_one_test("2001-12-31",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARS "='2001' "
+ PCMK_XA_MONTHS "='12' "
+ PCMK_XA_MONTHDAYS "='30'/>",
+ pcmk_rc_after_range);
+}
+
+static void
+time_after_monthdays_range(void **state)
+{
+ run_one_test("2001-06-10",
+ "<" PCMK_XE_DATE_SPEC " "
+ PCMK_XA_ID "='spec' "
+ PCMK_XA_YEARS "='2001' "
+ PCMK_XA_MONTHS "='6' "
+ PCMK_XA_MONTHDAYS "='11-15'/>",
+ pcmk_rc_before_range);
+}
+
+PCMK__UNIT_TEST(pcmk__xml_test_setup_group, NULL,
+ cmocka_unit_test(null_invalid),
+ cmocka_unit_test(spec_id_missing),
+ cmocka_unit_test(invalid_range),
+ cmocka_unit_test(time_satisfies_year_spec),
+ cmocka_unit_test(time_after_year_spec),
+ cmocka_unit_test(time_satisfies_year_range),
+ cmocka_unit_test(time_before_year_range),
+ cmocka_unit_test(time_after_year_range),
+ cmocka_unit_test(range_without_start_year_passes),
+ cmocka_unit_test(range_without_end_year_passes),
+ cmocka_unit_test(yeardays_satisfies),
+ cmocka_unit_test(time_after_yeardays_spec),
+ cmocka_unit_test(yeardays_feb_29_satisfies),
+ cmocka_unit_test(exact_ymd_satisfies),
+ cmocka_unit_test(range_in_month_satisfies),
+ cmocka_unit_test(exact_ymd_after_range),
+ cmocka_unit_test(time_after_monthdays_range))
diff --git a/lib/common/tests/rules/pcmk__evaluate_op_expression_test.c b/lib/common/tests/rules/pcmk__evaluate_op_expression_test.c
new file mode 100644
index 0000000..d1cb35f
--- /dev/null
+++ b/lib/common/tests/rules/pcmk__evaluate_op_expression_test.c
@@ -0,0 +1,207 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <stdio.h>
+#include <glib.h>
+
+#include <crm/common/xml.h>
+#include <crm/common/rules_internal.h>
+#include <crm/common/unittest_internal.h>
+#include "crmcommon_private.h"
+
+/*
+ * Shared data
+ */
+
+static pcmk_rule_input_t rule_input = {
+ // These are the only members used to evaluate operation expressions
+ .op_name = PCMK_ACTION_MONITOR,
+ .op_interval_ms = 10000,
+};
+
+/*!
+ * \internal
+ * \brief Run one test, comparing return value
+ *
+ * \param[in] xml_string Operation expression XML as string
+ * \param[in] reference_rc Assert that evaluation result equals this
+ */
+static void
+assert_op_expression(const char *xml_string, int reference_rc)
+{
+ xmlNode *xml = pcmk__xml_parse(xml_string);
+
+ assert_int_equal(pcmk__evaluate_op_expression(xml, &rule_input),
+ reference_rc);
+ free_xml(xml);
+}
+
+
+/*
+ * Invalid arguments
+ */
+
+#define EXPR_FAIL_BOTH \
+ "<" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_START "' " \
+ PCMK_XA_INTERVAL "='0' />"
+
+static void
+null_invalid(void **state)
+{
+ xmlNode *xml = NULL;
+
+ assert_int_equal(pcmk__evaluate_op_expression(NULL, NULL), EINVAL);
+
+ xml = pcmk__xml_parse(EXPR_FAIL_BOTH);
+ assert_int_equal(pcmk__evaluate_op_expression(xml, NULL), EINVAL);
+ free_xml(xml);
+
+ assert_op_expression(NULL, EINVAL);
+}
+
+
+/*
+ * Test PCMK_XA_ID
+ */
+
+#define EXPR_ID_MISSING \
+ "<" PCMK_XE_OP_EXPRESSION " " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' />"
+
+#define EXPR_ID_EMPTY \
+ "<" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' />"
+
+static void
+id_missing(void **state)
+{
+ // Currently acceptable
+ assert_op_expression(EXPR_ID_MISSING, pcmk_rc_ok);
+ assert_op_expression(EXPR_ID_EMPTY, pcmk_rc_ok);
+}
+
+
+/*
+ * Test PCMK_XA_NAME
+ */
+
+#define EXPR_NAME_MISSING \
+ "<" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_INTERVAL "='10s' />"
+
+static void
+name_missing(void **state)
+{
+ assert_op_expression(EXPR_NAME_MISSING, pcmk_rc_unpack_error);
+}
+
+#define EXPR_MATCH_BOTH \
+ "<" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' />"
+
+#define EXPR_EMPTY_NAME \
+ "<" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_NAME "='' " PCMK_XA_INTERVAL "='10s' />"
+
+static void
+input_name_missing(void **state)
+{
+ rule_input.op_name = NULL;
+ assert_op_expression(EXPR_MATCH_BOTH, pcmk_rc_op_unsatisfied);
+ assert_op_expression(EXPR_EMPTY_NAME, pcmk_rc_op_unsatisfied);
+ rule_input.op_name = PCMK_ACTION_MONITOR;
+}
+
+#define EXPR_FAIL_NAME \
+ "<" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_START "' " \
+ PCMK_XA_INTERVAL "='10s' />"
+
+static void
+fail_name(void **state)
+{
+ assert_op_expression(EXPR_FAIL_NAME, pcmk_rc_op_unsatisfied);
+
+ // An empty name is meaningless but accepted, so not an unpack error
+ assert_op_expression(EXPR_EMPTY_NAME, pcmk_rc_op_unsatisfied);
+}
+
+
+/*
+ * Test PCMK_XA_INTERVAL
+ */
+
+#define EXPR_EMPTY_INTERVAL \
+ "<" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='' />"
+
+#define EXPR_INVALID_INTERVAL \
+ "<" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='not-an-interval' />"
+
+static void
+invalid_interval(void **state)
+{
+ assert_op_expression(EXPR_EMPTY_INTERVAL, pcmk_rc_unpack_error);
+ assert_op_expression(EXPR_INVALID_INTERVAL, pcmk_rc_unpack_error);
+}
+
+#define EXPR_DEFAULT_INTERVAL \
+ "<" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' />"
+
+static void
+default_interval(void **state)
+{
+ assert_op_expression(EXPR_DEFAULT_INTERVAL, pcmk_rc_ok);
+}
+
+#define EXPR_FAIL_INTERVAL \
+ "<" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='9s' />"
+
+static void
+fail_interval(void **state)
+{
+ assert_op_expression(EXPR_FAIL_INTERVAL, pcmk_rc_op_unsatisfied);
+}
+
+
+static void
+match_both(void **state)
+{
+ assert_op_expression(EXPR_MATCH_BOTH, pcmk_rc_ok);
+}
+
+static void
+fail_both(void **state)
+{
+ assert_op_expression(EXPR_FAIL_BOTH, pcmk_rc_op_unsatisfied);
+}
+
+PCMK__UNIT_TEST(pcmk__xml_test_setup_group, NULL,
+ cmocka_unit_test(null_invalid),
+ cmocka_unit_test(id_missing),
+ cmocka_unit_test(name_missing),
+ cmocka_unit_test(input_name_missing),
+ cmocka_unit_test(fail_name),
+ cmocka_unit_test(invalid_interval),
+ cmocka_unit_test(default_interval),
+ cmocka_unit_test(fail_interval),
+ cmocka_unit_test(match_both),
+ cmocka_unit_test(fail_both))
diff --git a/lib/common/tests/rules/pcmk__evaluate_rsc_expression_test.c b/lib/common/tests/rules/pcmk__evaluate_rsc_expression_test.c
new file mode 100644
index 0000000..c3a164e
--- /dev/null
+++ b/lib/common/tests/rules/pcmk__evaluate_rsc_expression_test.c
@@ -0,0 +1,227 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <stdio.h>
+#include <glib.h>
+
+#include <crm/common/xml.h>
+#include <crm/common/rules_internal.h>
+#include <crm/common/unittest_internal.h>
+#include "crmcommon_private.h"
+
+/*
+ * Shared data
+ */
+
+static pcmk_rule_input_t rule_input = {
+ // These are the only members used to evaluate resource expressions
+ .rsc_standard = PCMK_RESOURCE_CLASS_OCF,
+ .rsc_provider = "heartbeat",
+ .rsc_agent = "IPaddr2",
+};
+
+/*!
+ * \internal
+ * \brief Run one test, comparing return value
+ *
+ * \param[in] xml_string Resource expression XML as string
+ * \param[in] reference_rc Assert that evaluation result equals this
+ */
+static void
+assert_rsc_expression(const char *xml_string, int reference_rc)
+{
+ xmlNode *xml = pcmk__xml_parse(xml_string);
+
+ assert_int_equal(pcmk__evaluate_rsc_expression(xml, &rule_input),
+ reference_rc);
+ free_xml(xml);
+}
+
+
+/*
+ * Invalid arguments
+ */
+
+#define EXPR_ALL_MATCH \
+ "<" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_CLASS "='" PCMK_RESOURCE_CLASS_OCF "' " \
+ PCMK_XA_PROVIDER "='heartbeat' " \
+ PCMK_XA_TYPE "='IPaddr2' />"
+
+static void
+null_invalid(void **state)
+{
+ xmlNode *xml = NULL;
+
+ assert_int_equal(pcmk__evaluate_rsc_expression(NULL, NULL), EINVAL);
+
+ xml = pcmk__xml_parse(EXPR_ALL_MATCH);
+ assert_int_equal(pcmk__evaluate_rsc_expression(xml, NULL), EINVAL);
+ free_xml(xml);
+
+ assert_rsc_expression(NULL, EINVAL);
+}
+
+
+/*
+ * Test PCMK_XA_ID
+ */
+
+#define EXPR_ID_MISSING \
+ "<" PCMK_XE_RSC_EXPRESSION " " \
+ PCMK_XA_CLASS "='" PCMK_RESOURCE_CLASS_OCF "' " \
+ PCMK_XA_PROVIDER "='heartbeat' " \
+ PCMK_XA_TYPE "='IPaddr2' />"
+
+#define EXPR_ID_EMPTY \
+ "<" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='' " \
+ PCMK_XA_CLASS "='" PCMK_RESOURCE_CLASS_OCF "' " \
+ PCMK_XA_PROVIDER "='heartbeat' " \
+ PCMK_XA_TYPE "='IPaddr2' />"
+
+static void
+id_missing(void **state)
+{
+ // Currently acceptable
+ assert_rsc_expression(EXPR_ID_MISSING, pcmk_rc_ok);
+ assert_rsc_expression(EXPR_ID_EMPTY, pcmk_rc_ok);
+}
+
+
+/*
+ * Test standard, provider, and agent
+ */
+
+#define EXPR_FAIL_STANDARD \
+ "<" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_CLASS "='" PCMK_RESOURCE_CLASS_LSB "' />"
+
+#define EXPR_EMPTY_STANDARD \
+ "<" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_CLASS "='' />"
+
+static void
+fail_standard(void **state)
+{
+ assert_rsc_expression(EXPR_FAIL_STANDARD, pcmk_rc_op_unsatisfied);
+ assert_rsc_expression(EXPR_EMPTY_STANDARD, pcmk_rc_op_unsatisfied);
+
+ rule_input.rsc_standard = NULL;
+ assert_rsc_expression(EXPR_FAIL_STANDARD, pcmk_rc_op_unsatisfied);
+ assert_rsc_expression(EXPR_EMPTY_STANDARD, pcmk_rc_op_unsatisfied);
+ rule_input.rsc_standard = PCMK_RESOURCE_CLASS_OCF;
+}
+
+#define EXPR_FAIL_PROVIDER \
+ "<" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_CLASS "='" PCMK_RESOURCE_CLASS_OCF "' " \
+ PCMK_XA_PROVIDER "='pacemaker' " \
+ PCMK_XA_TYPE "='IPaddr2' />"
+
+#define EXPR_EMPTY_PROVIDER \
+ "<" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_CLASS "='" PCMK_RESOURCE_CLASS_OCF "' " \
+ PCMK_XA_PROVIDER "='' " PCMK_XA_TYPE "='IPaddr2' />"
+
+static void
+fail_provider(void **state)
+{
+ assert_rsc_expression(EXPR_FAIL_PROVIDER, pcmk_rc_op_unsatisfied);
+ assert_rsc_expression(EXPR_EMPTY_PROVIDER, pcmk_rc_op_unsatisfied);
+
+ rule_input.rsc_provider = NULL;
+ assert_rsc_expression(EXPR_FAIL_PROVIDER, pcmk_rc_op_unsatisfied);
+ assert_rsc_expression(EXPR_EMPTY_PROVIDER, pcmk_rc_op_unsatisfied);
+ rule_input.rsc_provider = "heartbeat";
+}
+
+#define EXPR_FAIL_AGENT \
+ "<" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_CLASS "='" PCMK_RESOURCE_CLASS_OCF "' " \
+ PCMK_XA_PROVIDER "='heartbeat' " \
+ PCMK_XA_TYPE "='IPaddr3' />"
+
+#define EXPR_EMPTY_AGENT \
+ "<" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_CLASS "='" PCMK_RESOURCE_CLASS_OCF "' " \
+ PCMK_XA_PROVIDER "='heartbeat' " PCMK_XA_TYPE "='' />"
+
+static void
+fail_agent(void **state)
+{
+ assert_rsc_expression(EXPR_FAIL_AGENT, pcmk_rc_op_unsatisfied);
+ assert_rsc_expression(EXPR_EMPTY_AGENT, pcmk_rc_op_unsatisfied);
+
+ rule_input.rsc_agent = NULL;
+ assert_rsc_expression(EXPR_FAIL_AGENT, pcmk_rc_op_unsatisfied);
+ assert_rsc_expression(EXPR_EMPTY_AGENT, pcmk_rc_op_unsatisfied);
+ rule_input.rsc_agent = "IPaddr2";
+}
+
+#define EXPR_NO_STANDARD_MATCHES \
+ "<" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_PROVIDER "='heartbeat' " \
+ PCMK_XA_TYPE "='IPaddr2' />"
+
+static void
+no_standard_matches(void **state)
+{
+ assert_rsc_expression(EXPR_NO_STANDARD_MATCHES, pcmk_rc_ok);
+}
+
+#define EXPR_NO_PROVIDER_MATCHES \
+ "<" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_CLASS "='" PCMK_RESOURCE_CLASS_OCF "' " \
+ PCMK_XA_TYPE "='IPaddr2' />"
+
+static void
+no_provider_matches(void **state)
+{
+ assert_rsc_expression(EXPR_NO_PROVIDER_MATCHES, pcmk_rc_ok);
+}
+
+#define EXPR_NO_AGENT_MATCHES \
+ "<" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_CLASS "='" PCMK_RESOURCE_CLASS_OCF "' " \
+ PCMK_XA_PROVIDER "='heartbeat' />"
+
+static void
+no_agent_matches(void **state)
+{
+ assert_rsc_expression(EXPR_NO_AGENT_MATCHES, pcmk_rc_ok);
+}
+
+#define EXPR_NO_CRITERIA_MATCHES \
+ "<" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e' />"
+
+static void
+no_criteria_matches(void **state)
+{
+ assert_rsc_expression(EXPR_NO_CRITERIA_MATCHES, pcmk_rc_ok);
+}
+
+static void
+all_match(void **state)
+{
+ assert_rsc_expression(EXPR_ALL_MATCH, pcmk_rc_ok);
+}
+
+PCMK__UNIT_TEST(pcmk__xml_test_setup_group, NULL,
+ cmocka_unit_test(null_invalid),
+ cmocka_unit_test(id_missing),
+ cmocka_unit_test(fail_standard),
+ cmocka_unit_test(fail_provider),
+ cmocka_unit_test(fail_agent),
+ cmocka_unit_test(no_standard_matches),
+ cmocka_unit_test(no_provider_matches),
+ cmocka_unit_test(no_agent_matches),
+ cmocka_unit_test(no_criteria_matches),
+ cmocka_unit_test(all_match))
diff --git a/lib/common/tests/rules/pcmk__parse_combine_test.c b/lib/common/tests/rules/pcmk__parse_combine_test.c
new file mode 100644
index 0000000..afebcf8
--- /dev/null
+++ b/lib/common/tests/rules/pcmk__parse_combine_test.c
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <stdio.h>
+
+#include <crm/common/rules_internal.h>
+#include <crm/common/unittest_internal.h>
+
+static void
+default_and(void **state)
+{
+ assert_int_equal(pcmk__parse_combine(NULL), pcmk__combine_and);
+}
+
+static void
+invalid(void **state)
+{
+ assert_int_equal(pcmk__parse_combine(""), pcmk__combine_unknown);
+ assert_int_equal(pcmk__parse_combine(" "), pcmk__combine_unknown);
+ assert_int_equal(pcmk__parse_combine("but"), pcmk__combine_unknown);
+}
+
+static void
+valid(void **state)
+{
+ assert_int_equal(pcmk__parse_combine(PCMK_VALUE_AND), pcmk__combine_and);
+ assert_int_equal(pcmk__parse_combine(PCMK_VALUE_OR), pcmk__combine_or);
+}
+
+static void
+case_insensitive(void **state)
+{
+ assert_int_equal(pcmk__parse_combine("And"),
+ pcmk__combine_and);
+
+ assert_int_equal(pcmk__parse_combine("OR"),
+ pcmk__combine_or);
+}
+
+PCMK__UNIT_TEST(NULL, NULL,
+ cmocka_unit_test(default_and),
+ cmocka_unit_test(invalid),
+ cmocka_unit_test(valid),
+ cmocka_unit_test(case_insensitive))
diff --git a/lib/common/tests/rules/pcmk__parse_comparison_test.c b/lib/common/tests/rules/pcmk__parse_comparison_test.c
new file mode 100644
index 0000000..a995596
--- /dev/null
+++ b/lib/common/tests/rules/pcmk__parse_comparison_test.c
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <stdio.h>
+
+#include <crm/common/rules_internal.h>
+#include <crm/common/unittest_internal.h>
+#include "crmcommon_private.h"
+
+static void
+null_unknown(void **state)
+{
+ assert_int_equal(pcmk__parse_comparison(NULL), pcmk__comparison_unknown);
+}
+
+static void
+invalid(void **state)
+{
+ assert_int_equal(pcmk__parse_comparison("nope"), pcmk__comparison_unknown);
+}
+
+static void
+valid(void **state)
+{
+ assert_int_equal(pcmk__parse_comparison(PCMK_VALUE_DEFINED),
+ pcmk__comparison_defined);
+
+ assert_int_equal(pcmk__parse_comparison(PCMK_VALUE_NOT_DEFINED),
+ pcmk__comparison_undefined);
+
+ assert_int_equal(pcmk__parse_comparison(PCMK_VALUE_EQ),
+ pcmk__comparison_eq);
+
+ assert_int_equal(pcmk__parse_comparison(PCMK_VALUE_NE),
+ pcmk__comparison_ne);
+
+ assert_int_equal(pcmk__parse_comparison(PCMK_VALUE_LT),
+ pcmk__comparison_lt);
+
+ assert_int_equal(pcmk__parse_comparison(PCMK_VALUE_LTE),
+ pcmk__comparison_lte);
+
+ assert_int_equal(pcmk__parse_comparison(PCMK_VALUE_GT),
+ pcmk__comparison_gt);
+
+ assert_int_equal(pcmk__parse_comparison(PCMK_VALUE_GTE),
+ pcmk__comparison_gte);
+}
+
+static void
+case_insensitive(void **state)
+{
+ assert_int_equal(pcmk__parse_comparison("DEFINED"),
+ pcmk__comparison_defined);
+
+ assert_int_equal(pcmk__parse_comparison("Not_Defined"),
+ pcmk__comparison_undefined);
+}
+
+PCMK__UNIT_TEST(NULL, NULL,
+ cmocka_unit_test(null_unknown),
+ cmocka_unit_test(invalid),
+ cmocka_unit_test(valid),
+ cmocka_unit_test(case_insensitive))
diff --git a/lib/common/tests/rules/pcmk__parse_source_test.c b/lib/common/tests/rules/pcmk__parse_source_test.c
new file mode 100644
index 0000000..9cf9b32
--- /dev/null
+++ b/lib/common/tests/rules/pcmk__parse_source_test.c
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <stdio.h>
+
+#include <crm/common/rules_internal.h>
+#include <crm/common/unittest_internal.h>
+#include "crmcommon_private.h"
+
+static void
+default_literal(void **state)
+{
+ assert_int_equal(pcmk__parse_source(NULL), pcmk__source_literal);
+}
+
+static void
+invalid(void **state)
+{
+ assert_int_equal(pcmk__parse_source(""), pcmk__source_unknown);
+ assert_int_equal(pcmk__parse_source(" "), pcmk__source_unknown);
+ assert_int_equal(pcmk__parse_source("params"), pcmk__source_unknown);
+}
+
+static void
+valid(void **state)
+{
+ assert_int_equal(pcmk__parse_source(PCMK_VALUE_LITERAL),
+ pcmk__source_literal);
+
+ assert_int_equal(pcmk__parse_source(PCMK_VALUE_PARAM),
+ pcmk__source_instance_attrs);
+
+ assert_int_equal(pcmk__parse_source(PCMK_VALUE_META),
+ pcmk__source_meta_attrs);
+}
+
+static void
+case_insensitive(void **state)
+{
+ assert_int_equal(pcmk__parse_source("LITERAL"),
+ pcmk__source_literal);
+
+ assert_int_equal(pcmk__parse_source("Param"),
+ pcmk__source_instance_attrs);
+
+ assert_int_equal(pcmk__parse_source("MeTa"),
+ pcmk__source_meta_attrs);
+}
+
+PCMK__UNIT_TEST(NULL, NULL,
+ cmocka_unit_test(default_literal),
+ cmocka_unit_test(invalid),
+ cmocka_unit_test(valid),
+ cmocka_unit_test(case_insensitive))
diff --git a/lib/common/tests/rules/pcmk__parse_type_test.c b/lib/common/tests/rules/pcmk__parse_type_test.c
new file mode 100644
index 0000000..96f02c8
--- /dev/null
+++ b/lib/common/tests/rules/pcmk__parse_type_test.c
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <stdio.h>
+
+#include <crm/common/rules_internal.h>
+#include <crm/common/unittest_internal.h>
+#include "crmcommon_private.h"
+
+static void
+invalid(void **state)
+{
+ assert_int_equal(pcmk__parse_type("nope", pcmk__comparison_unknown,
+ NULL, NULL),
+ pcmk__type_unknown);
+}
+
+static void
+valid(void **state)
+{
+ assert_int_equal(pcmk__parse_type(PCMK_VALUE_STRING,
+ pcmk__comparison_unknown, NULL, NULL),
+ pcmk__type_string);
+
+ assert_int_equal(pcmk__parse_type(PCMK_VALUE_INTEGER,
+ pcmk__comparison_unknown, NULL, NULL),
+ pcmk__type_integer);
+
+ assert_int_equal(pcmk__parse_type(PCMK_VALUE_NUMBER,
+ pcmk__comparison_unknown, NULL, NULL),
+ pcmk__type_number);
+
+ assert_int_equal(pcmk__parse_type(PCMK_VALUE_VERSION,
+ pcmk__comparison_unknown, NULL, NULL),
+ pcmk__type_version);
+}
+
+static void
+case_insensitive(void **state)
+{
+ assert_int_equal(pcmk__parse_type("STRING", pcmk__comparison_unknown,
+ NULL, NULL),
+ pcmk__type_string);
+
+ assert_int_equal(pcmk__parse_type("Integer", pcmk__comparison_unknown,
+ NULL, NULL),
+ pcmk__type_integer);
+}
+
+static void
+default_number(void **state)
+{
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_lt, "1.0", "2.5"),
+ pcmk__type_number);
+
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_lte, "1.", "2"),
+ pcmk__type_number);
+
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_gt, "1", ".5"),
+ pcmk__type_number);
+
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_gte, "1.0", "2"),
+ pcmk__type_number);
+}
+
+static void
+default_integer(void **state)
+{
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_lt, "1", "2"),
+ pcmk__type_integer);
+
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_lte, "1", "2"),
+ pcmk__type_integer);
+
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_gt, "1", "2"),
+ pcmk__type_integer);
+
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_gte, "1", "2"),
+ pcmk__type_integer);
+
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_gte, NULL, NULL),
+ pcmk__type_integer);
+
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_gte, "1", NULL),
+ pcmk__type_integer);
+
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_gte, NULL, "2.5"),
+ pcmk__type_number);
+}
+
+static void
+default_string(void **state)
+{
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_unknown,
+ NULL, NULL),
+ pcmk__type_string);
+
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_defined,
+ NULL, NULL),
+ pcmk__type_string);
+
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_undefined,
+ NULL, NULL),
+ pcmk__type_string);
+
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_eq, NULL, NULL),
+ pcmk__type_string);
+
+ assert_int_equal(pcmk__parse_type(NULL, pcmk__comparison_ne, NULL, NULL),
+ pcmk__type_string);
+}
+
+PCMK__UNIT_TEST(NULL, NULL,
+ cmocka_unit_test(invalid),
+ cmocka_unit_test(valid),
+ cmocka_unit_test(case_insensitive),
+ cmocka_unit_test(default_number),
+ cmocka_unit_test(default_integer),
+ cmocka_unit_test(default_string))
diff --git a/lib/common/tests/rules/pcmk__replace_submatches_test.c b/lib/common/tests/rules/pcmk__replace_submatches_test.c
new file mode 100644
index 0000000..d404fcc
--- /dev/null
+++ b/lib/common/tests/rules/pcmk__replace_submatches_test.c
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <regex.h> // regmatch_t
+
+#include <crm/common/rules_internal.h>
+#include <crm/common/unittest_internal.h>
+
+// An example matched string with submatches
+static const char *match = "this is a string";
+static const regmatch_t submatches[] = {
+ { .rm_so = 0, .rm_eo = 16 }, // %0 = entire string
+ { .rm_so = 5, .rm_eo = 7 }, // %1 = "is"
+ { .rm_so = 9, .rm_eo = 9 }, // %2 = empty match
+};
+static const int nmatches = 3;
+
+static void
+assert_submatch(const char *string, const char *reference)
+{
+ char *expanded = NULL;
+
+ expanded = pcmk__replace_submatches(string, match, submatches, nmatches);
+ if ((expanded == NULL) || (reference == NULL)) {
+ assert_null(expanded);
+ assert_null(reference);
+ } else {
+ assert_int_equal(strcmp(expanded, reference), 0);
+ }
+ free(expanded);
+}
+
+static void
+no_source(void **state)
+{
+ assert_null(pcmk__replace_submatches(NULL, NULL, NULL, 0));
+ assert_submatch(NULL, NULL);
+ assert_submatch("", NULL);
+}
+
+static void
+source_has_no_variables(void **state)
+{
+ assert_null(pcmk__replace_submatches("this has no submatch variables",
+ match, submatches, nmatches));
+ assert_null(pcmk__replace_submatches("this ends in a %",
+ match, submatches, nmatches));
+ assert_null(pcmk__replace_submatches("%this starts with one",
+ match, submatches, nmatches));
+}
+
+static void
+without_matches(void **state)
+{
+ assert_submatch("this has an empty submatch %2",
+ "this has an empty submatch ");
+ assert_submatch("this has a nonexistent submatch %3",
+ "this has a nonexistent submatch ");
+}
+
+static void
+with_matches(void **state)
+{
+ assert_submatch("%0", match); // %0 matches entire string
+ assert_submatch("this %1", "this is");
+ assert_submatch("%1 this %ok", "is this %ok");
+}
+
+PCMK__UNIT_TEST(NULL, NULL,
+ cmocka_unit_test(no_source),
+ cmocka_unit_test(source_has_no_variables),
+ cmocka_unit_test(without_matches),
+ cmocka_unit_test(with_matches))
diff --git a/lib/common/tests/rules/pcmk__unpack_duration_test.c b/lib/common/tests/rules/pcmk__unpack_duration_test.c
new file mode 100644
index 0000000..e82546c
--- /dev/null
+++ b/lib/common/tests/rules/pcmk__unpack_duration_test.c
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <glib.h>
+
+#include <crm/common/unittest_internal.h>
+
+#include <crm/common/iso8601.h>
+#include <crm/common/xml.h>
+#include "../../crmcommon_private.h"
+
+#define MONTHS_TO_SECONDS "months=\"2\" weeks=\"3\" days=\"-1\" " \
+ "hours=\"1\" minutes=\"1\" seconds=\"1\" />"
+
+#define ALL_VALID "<duration id=\"duration1\" years=\"1\" " MONTHS_TO_SECONDS
+
+#define NO_ID "<duration years=\"1\" " MONTHS_TO_SECONDS
+
+#define YEARS_INVALID "<duration id=\"duration1\" years=\"not-a-number\" " \
+ MONTHS_TO_SECONDS
+
+static void
+null_invalid(void **state)
+{
+ xmlNode *duration = pcmk__xml_parse(ALL_VALID);
+ crm_time_t *start = crm_time_new("2024-01-01 15:00:00");
+ crm_time_t *end = NULL;
+
+ assert_int_equal(pcmk__unpack_duration(NULL, NULL, NULL), EINVAL);
+ assert_int_equal(pcmk__unpack_duration(duration, NULL, NULL), EINVAL);
+ assert_int_equal(pcmk__unpack_duration(duration, start, NULL), EINVAL);
+ assert_int_equal(pcmk__unpack_duration(duration, NULL, &end), EINVAL);
+ assert_int_equal(pcmk__unpack_duration(NULL, start, NULL), EINVAL);
+ assert_int_equal(pcmk__unpack_duration(NULL, start, &end), EINVAL);
+ assert_int_equal(pcmk__unpack_duration(NULL, NULL, &end), EINVAL);
+
+ crm_time_free(start);
+ free_xml(duration);
+}
+
+static void
+nonnull_end_invalid(void **state)
+{
+ xmlNode *duration = pcmk__xml_parse(ALL_VALID);
+ crm_time_t *start = crm_time_new("2024-01-01 15:00:00");
+ crm_time_t *end = crm_time_new("2024-01-01 15:00:01");
+
+ assert_int_equal(pcmk__unpack_duration(duration, start, &end), EINVAL);
+
+ crm_time_free(start);
+ crm_time_free(end);
+ free_xml(duration);
+}
+
+static void
+no_id(void **state)
+{
+ xmlNode *duration = pcmk__xml_parse(NO_ID);
+ crm_time_t *start = crm_time_new("2024-01-01 15:00:00");
+ crm_time_t *end = NULL;
+ crm_time_t *reference = crm_time_new("2025-03-21 16:01:01");
+
+ assert_int_equal(pcmk__unpack_duration(duration, start, &end), pcmk_rc_ok);
+ assert_int_equal(crm_time_compare(end, reference), 0);
+
+ crm_time_free(start);
+ crm_time_free(end);
+ crm_time_free(reference);
+ free_xml(duration);
+}
+
+static void
+years_invalid(void **state)
+{
+ xmlNode *duration = pcmk__xml_parse(YEARS_INVALID);
+ crm_time_t *start = crm_time_new("2024-01-01 15:00:00");
+ crm_time_t *end = NULL;
+ crm_time_t *reference = crm_time_new("2024-03-21 16:01:01");
+
+ assert_int_equal(pcmk__unpack_duration(duration, start, &end),
+ pcmk_rc_unpack_error);
+ assert_int_equal(crm_time_compare(end, reference), 0);
+
+ crm_time_free(start);
+ crm_time_free(end);
+ crm_time_free(reference);
+ free_xml(duration);
+}
+
+static void
+all_valid(void **state)
+{
+ xmlNode *duration = pcmk__xml_parse(ALL_VALID);
+ crm_time_t *start = crm_time_new("2024-01-01 15:00:00");
+ crm_time_t *end = NULL;
+ crm_time_t *reference = crm_time_new("2025-03-21 16:01:01");
+
+ assert_int_equal(pcmk__unpack_duration(duration, start, &end), pcmk_rc_ok);
+ assert_int_equal(crm_time_compare(end, reference), 0);
+
+ crm_time_free(start);
+ crm_time_free(end);
+ crm_time_free(reference);
+ free_xml(duration);
+}
+
+PCMK__UNIT_TEST(pcmk__xml_test_setup_group, NULL,
+ cmocka_unit_test(null_invalid),
+ cmocka_unit_test(nonnull_end_invalid),
+ cmocka_unit_test(no_id),
+ cmocka_unit_test(years_invalid),
+ cmocka_unit_test(all_valid))
diff --git a/lib/common/tests/rules/pcmk_evaluate_rule_test.c b/lib/common/tests/rules/pcmk_evaluate_rule_test.c
new file mode 100644
index 0000000..6b6f9eb
--- /dev/null
+++ b/lib/common/tests/rules/pcmk_evaluate_rule_test.c
@@ -0,0 +1,379 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <stdio.h>
+#include <glib.h>
+
+#include <crm/common/xml.h>
+#include <crm/common/rules_internal.h>
+#include <crm/common/unittest_internal.h>
+
+/*
+ * Shared data
+ */
+
+static pcmk_rule_input_t rule_input = {
+ .rsc_standard = PCMK_RESOURCE_CLASS_OCF,
+ .rsc_provider = "heartbeat",
+ .rsc_agent = "IPaddr2",
+ .op_name = PCMK_ACTION_MONITOR,
+ .op_interval_ms = 10000,
+};
+
+
+/*
+ * Test invalid arguments
+ */
+
+#define RULE_OP \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' > " \
+ " <" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' />" \
+ "</" PCMK_XE_RULE ">"
+
+static void
+null_invalid(void **state)
+{
+ xmlNode *xml = NULL;
+ crm_time_t *next_change = crm_time_new_undefined();
+
+ assert_int_equal(pcmk_evaluate_rule(NULL, NULL, next_change),
+ EINVAL);
+
+ xml = pcmk__xml_parse(RULE_OP);
+ assert_int_equal(pcmk_evaluate_rule(xml, NULL, next_change), EINVAL);
+ free_xml(xml);
+
+ assert_int_equal(pcmk_evaluate_rule(NULL, &rule_input, next_change),
+ EINVAL);
+
+ crm_time_free(next_change);
+}
+
+#define RULE_OP_MISSING_ID \
+ "<" PCMK_XE_RULE "> " \
+ " <" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' />" \
+ "</" PCMK_XE_RULE ">"
+
+static void
+id_missing(void **state)
+{
+ // Currently acceptable
+ xmlNode *xml = pcmk__xml_parse(RULE_OP_MISSING_ID);
+ crm_time_t *next_change = crm_time_new_undefined();
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, next_change),
+ pcmk_rc_ok);
+
+ crm_time_free(next_change);
+ free_xml(xml);
+}
+
+#define RULE_IDREF_PARENT "<" PCMK_XE_CIB ">" RULE_OP "</" PCMK_XE_CIB ">"
+
+static void
+good_idref(void **state)
+{
+ xmlNode *parent_xml = pcmk__xml_parse(RULE_IDREF_PARENT);
+ xmlNode *rule_xml = pcmk__xe_create(parent_xml, PCMK_XE_RULE);
+ crm_time_t *next_change = crm_time_new_undefined();
+
+ crm_xml_add(rule_xml, PCMK_XA_ID_REF, "r");
+ assert_int_equal(pcmk_evaluate_rule(rule_xml, &rule_input, next_change),
+ pcmk_rc_ok);
+
+ crm_time_free(next_change);
+ free_xml(parent_xml);
+}
+
+static void
+bad_idref(void **state)
+{
+ xmlNode *parent_xml = pcmk__xml_parse(RULE_IDREF_PARENT);
+ xmlNode *rule_xml = pcmk__xe_create(parent_xml, PCMK_XE_RULE);
+ crm_time_t *next_change = crm_time_new_undefined();
+
+ crm_xml_add(rule_xml, PCMK_XA_ID_REF, "x");
+ assert_int_equal(pcmk_evaluate_rule(rule_xml, &rule_input, next_change),
+ pcmk_rc_unpack_error);
+
+ crm_time_free(next_change);
+ free_xml(parent_xml);
+}
+
+#define RULE_EMPTY "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' />"
+
+static void
+empty_default(void **state)
+{
+ // Currently acceptable
+ xmlNode *xml = pcmk__xml_parse(RULE_EMPTY);
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, NULL),
+ pcmk_rc_ok);
+
+ free_xml(xml);
+}
+
+#define RULE_EMPTY_AND \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' " \
+ PCMK_XA_BOOLEAN_OP "='" PCMK_VALUE_AND "' />"
+
+static void
+empty_and(void **state)
+{
+ // Currently acceptable
+ xmlNode *xml = pcmk__xml_parse(RULE_EMPTY_AND);
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, NULL),
+ pcmk_rc_ok);
+
+ free_xml(xml);
+}
+
+#define RULE_EMPTY_OR \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' " \
+ PCMK_XA_BOOLEAN_OP "='" PCMK_VALUE_OR "' />"
+
+static void
+empty_or(void **state)
+{
+ // Currently treated as unsatisfied
+ xmlNode *xml = pcmk__xml_parse(RULE_EMPTY_OR);
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, NULL),
+ pcmk_rc_op_unsatisfied);
+
+ free_xml(xml);
+}
+
+#define RULE_DEFAULT_BOOLEAN_OP \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' >" \
+ " <" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e1' " \
+ PCMK_XA_TYPE "='Dummy' />" \
+ " <" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e2' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' />" \
+ "</" PCMK_XE_RULE ">"
+
+static void
+default_boolean_op(void **state)
+{
+ // Defaults to PCMK_VALUE_AND
+ xmlNode *xml = pcmk__xml_parse(RULE_DEFAULT_BOOLEAN_OP);
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, NULL),
+ pcmk_rc_op_unsatisfied);
+
+ free_xml(xml);
+}
+
+#define RULE_INVALID_BOOLEAN_OP \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' " \
+ PCMK_XA_BOOLEAN_OP "='not-an-op' >" \
+ " <" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e1' " \
+ PCMK_XA_TYPE "='Dummy' />" \
+ " <" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e2' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' />" \
+ "</" PCMK_XE_RULE ">"
+
+static void
+invalid_boolean_op(void **state)
+{
+ // Currently defaults to PCMK_VALUE_AND
+ xmlNode *xml = pcmk__xml_parse(RULE_INVALID_BOOLEAN_OP);
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, NULL),
+ pcmk_rc_op_unsatisfied);
+
+ free_xml(xml);
+}
+
+#define RULE_AND_PASSES \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' " \
+ PCMK_XA_BOOLEAN_OP "='" PCMK_VALUE_AND "' >" \
+ " <" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e1' " \
+ PCMK_XA_TYPE "='IPaddr2' />" \
+ " <" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e2' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' />" \
+ "</" PCMK_XE_RULE ">"
+
+static void
+and_passes(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(RULE_AND_PASSES);
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, NULL), pcmk_rc_ok);
+
+ free_xml(xml);
+}
+
+#define RULE_LONELY_AND \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' " \
+ PCMK_XA_BOOLEAN_OP "='" PCMK_VALUE_AND "' >" \
+ " <" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e1' " \
+ PCMK_XA_TYPE "='IPaddr2' />" \
+ "</" PCMK_XE_RULE ">"
+
+static void
+lonely_and_passes(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(RULE_LONELY_AND);
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, NULL), pcmk_rc_ok);
+
+ free_xml(xml);
+}
+
+#define RULE_AND_ONE_FAILS \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' " \
+ PCMK_XA_BOOLEAN_OP "='" PCMK_VALUE_AND "' >" \
+ " <" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e1' " \
+ PCMK_XA_TYPE "='Dummy' />" \
+ " <" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e2' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' />" \
+ "</" PCMK_XE_RULE ">"
+
+static void
+and_one_fails(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(RULE_AND_ONE_FAILS);
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, NULL),
+ pcmk_rc_op_unsatisfied);
+
+ free_xml(xml);
+}
+
+#define RULE_AND_TWO_FAIL \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' " \
+ PCMK_XA_BOOLEAN_OP "='" PCMK_VALUE_AND "' >" \
+ " <" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e1' " \
+ PCMK_XA_TYPE "='Dummy' />" \
+ " <" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e2' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='9s' />" \
+ "</" PCMK_XE_RULE ">"
+
+static void
+and_two_fail(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(RULE_AND_TWO_FAIL);
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, NULL),
+ pcmk_rc_op_unsatisfied);
+
+ free_xml(xml);
+}
+
+#define RULE_OR_ONE_PASSES \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' " \
+ PCMK_XA_BOOLEAN_OP "='" PCMK_VALUE_OR "' >" \
+ " <" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e1' " \
+ PCMK_XA_TYPE "='Dummy' />" \
+ " <" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e2' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' />" \
+ "</" PCMK_XE_RULE ">"
+
+static void
+or_one_passes(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(RULE_OR_ONE_PASSES);
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, NULL), pcmk_rc_ok);
+
+ free_xml(xml);
+}
+
+#define RULE_OR_TWO_PASS \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' " \
+ PCMK_XA_BOOLEAN_OP "='" PCMK_VALUE_OR "' >" \
+ " <" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e1' " \
+ PCMK_XA_TYPE "='IPAddr2' />" \
+ " <" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e2' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' />" \
+ "</" PCMK_XE_RULE ">"
+
+static void
+or_two_pass(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(RULE_OR_TWO_PASS);
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, NULL), pcmk_rc_ok);
+
+ free_xml(xml);
+}
+
+#define RULE_LONELY_OR \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' " \
+ PCMK_XA_BOOLEAN_OP "='" PCMK_VALUE_OR "' >" \
+ " <" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e2' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='10s' />" \
+ "</" PCMK_XE_RULE ">"
+
+static void
+lonely_or_passes(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(RULE_LONELY_OR);
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, NULL), pcmk_rc_ok);
+
+ free_xml(xml);
+}
+
+#define RULE_OR_FAILS \
+ "<" PCMK_XE_RULE " " PCMK_XA_ID "='r' " \
+ PCMK_XA_BOOLEAN_OP "='" PCMK_VALUE_OR "' >" \
+ " <" PCMK_XE_RSC_EXPRESSION " " PCMK_XA_ID "='e1' " \
+ PCMK_XA_TYPE "='Dummy' />" \
+ " <" PCMK_XE_OP_EXPRESSION " " PCMK_XA_ID "='e2' " \
+ PCMK_XA_NAME "='" PCMK_ACTION_MONITOR "' " \
+ PCMK_XA_INTERVAL "='20s' />" \
+ "</" PCMK_XE_RULE ">"
+
+static void
+or_fails(void **state)
+{
+ xmlNode *xml = pcmk__xml_parse(RULE_OR_FAILS);
+
+ assert_int_equal(pcmk_evaluate_rule(xml, &rule_input, NULL),
+ pcmk_rc_op_unsatisfied);
+
+ free_xml(xml);
+}
+
+PCMK__UNIT_TEST(pcmk__xml_test_setup_group, NULL,
+ cmocka_unit_test(null_invalid),
+ cmocka_unit_test(id_missing),
+ cmocka_unit_test(good_idref),
+ cmocka_unit_test(bad_idref),
+ cmocka_unit_test(empty_default),
+ cmocka_unit_test(empty_and),
+ cmocka_unit_test(empty_or),
+ cmocka_unit_test(default_boolean_op),
+ cmocka_unit_test(invalid_boolean_op),
+ cmocka_unit_test(and_passes),
+ cmocka_unit_test(lonely_and_passes),
+ cmocka_unit_test(and_one_fails),
+ cmocka_unit_test(and_two_fail),
+ cmocka_unit_test(or_one_passes),
+ cmocka_unit_test(or_two_pass),
+ cmocka_unit_test(lonely_or_passes),
+ cmocka_unit_test(or_fails))