Browse Source

Add a query rules tester API call (#114168) (#114747)

* Add a query rules tester API call

* Update docs/changelog/114168.yaml

* Wrap client call in async with origin

* Remove unused param

* PR feedback

* Remove redundant test

* CI workaround - add ent-search as ml dependency so it can find node features
Kathleen DeRusso 1 year ago
parent
commit
df62bcfce1
19 changed files with 951 additions and 8 deletions
  1. 5 0
      docs/changelog/114168.yaml
  2. 2 0
      docs/reference/query-rules/apis/index.asciidoc
  3. 133 0
      docs/reference/query-rules/apis/test-query-ruleset.asciidoc
  4. 38 0
      rest-api-spec/src/main/resources/rest-api-spec/api/query_rules.test.json
  5. 1 0
      server/src/main/java/org/elasticsearch/TransportVersions.java
  6. 252 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/70_query_rule_test.yml
  7. 6 1
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearch.java
  8. 9 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearchFeatures.java
  9. 7 6
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/QueryRule.java
  10. 2 1
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/GetQueryRulesetAction.java
  11. 53 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestTestQueryRulesetAction.java
  12. 212 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetAction.java
  13. 64 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TransportTestQueryRulesetAction.java
  14. 4 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/EnterpriseSearchModuleTestUtils.java
  15. 53 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/RestTestQueryRulesetActionTests.java
  16. 56 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetActionRequestBWCSerializingTests.java
  17. 52 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetActionResponseBWCSerializingTests.java
  18. 1 0
      x-pack/plugin/ml/qa/native-multi-node-tests/build.gradle
  19. 1 0
      x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java

+ 5 - 0
docs/changelog/114168.yaml

@@ -0,0 +1,5 @@
+pr: 114168
+summary: Add a query rules tester API call
+area: Relevance
+type: enhancement
+issues: []

+ 2 - 0
docs/reference/query-rules/apis/index.asciidoc

@@ -23,6 +23,7 @@ Use the following APIs to manage query rulesets:
 * <<put-query-rule>>
 * <<get-query-rule>>
 * <<delete-query-rule>>
+* preview:[] <<test-query-ruleset>>
 
 include::put-query-ruleset.asciidoc[]
 include::get-query-ruleset.asciidoc[]
@@ -31,4 +32,5 @@ include::delete-query-ruleset.asciidoc[]
 include::put-query-rule.asciidoc[]
 include::get-query-rule.asciidoc[]
 include::delete-query-rule.asciidoc[]
+include::test-query-ruleset.asciidoc[]
 

+ 133 - 0
docs/reference/query-rules/apis/test-query-ruleset.asciidoc

@@ -0,0 +1,133 @@
+[role="xpack"]
+[[test-query-ruleset]]
+=== Test query ruleset
+
+++++
+<titleabbrev>Tests query ruleset</titleabbrev>
+++++
+
+Evaluates match criteria against a query ruleset to identify the rules that would match that criteria.
+
+preview::[]
+
+[[test-query-ruleset-request]]
+==== {api-request-title}
+
+`POST _query_rules/<ruleset_id>/_test`
+
+[[test-query-ruleset-prereq]]
+==== {api-prereq-title}
+
+Requires the `manage_search_query_rules` privilege.
+
+[[test-query-ruleset-path-params]]
+==== {api-path-parms-title}
+
+`<ruleset_id>`::
+(Required, string)
+
+[[test-query-rule-request-body]]
+==== {api-request-body-title}
+
+`match_criteria`::
+(Required, object) Defines the match criteria to apply to rules in the given query ruleset.
+Match criteria should match the keys defined in the `criteria.metadata` field of the rule.
+
+[[test-query-ruleset-response-codes]]
+==== {api-response-codes-title}
+
+`400`::
+The `ruleset_id` or `match_criteria` were not provided.
+
+`404` (Missing resources)::
+No query ruleset matching `ruleset_id` could be found.
+
+[[test-query-ruleset-example]]
+==== {api-examples-title}
+
+To test a ruleset, provide the match criteria that you want to test against:
+
+////
+
+[source,console]
+--------------------------------------------------
+PUT _query_rules/my-ruleset
+{
+    "rules": [
+        {
+            "rule_id": "my-rule1",
+            "type": "pinned",
+            "criteria": [
+                {
+                    "type": "contains",
+                    "metadata": "query_string",
+                    "values": [ "pugs", "puggles" ]
+                }
+            ],
+            "actions": {
+                "ids": [
+                    "id1",
+                    "id2"
+                ]
+            }
+        },
+        {
+            "rule_id": "my-rule2",
+            "type": "pinned",
+            "criteria": [
+                {
+                    "type": "fuzzy",
+                    "metadata": "query_string",
+                    "values": [ "rescue dogs" ]
+                }
+            ],
+            "actions": {
+                "docs": [
+                    {
+                        "_index": "index1",
+                        "_id": "id3"
+                    },
+                    {
+                        "_index": "index2",
+                        "_id": "id4"
+                    }
+                ]
+            }
+        }
+    ]
+}
+--------------------------------------------------
+// TESTSETUP
+
+[source,console]
+--------------------------------------------------
+DELETE _query_rules/my-ruleset
+--------------------------------------------------
+// TEARDOWN
+
+////
+
+[source,console]
+----
+POST _query_rules/my-ruleset/_test
+{
+    "match_criteria": {
+        "query_string": "puggles"
+    }
+}
+----
+
+A sample response:
+
+[source,console-result]
+----
+{
+    "total_matched_rules": 1,
+    "matched_rules": [
+        {
+            "ruleset_id": "my-ruleset",
+            "rule_id": "my-rule1"
+        }
+    ]
+}
+----

+ 38 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/query_rules.test.json

@@ -0,0 +1,38 @@
+{
+  "query_rules.test": {
+    "documentation": {
+      "url": "https://www.elastic.co/guide/en/elasticsearch/reference/master/test-query-ruleset.html",
+      "description": "Tests a query ruleset to identify the rules that would match input criteria"
+    },
+    "stability": "experimental",
+    "visibility": "public",
+    "headers": {
+      "accept": [
+        "application/json"
+      ],
+      "content_type": [
+        "application/json"
+      ]
+    },
+    "url": {
+      "paths": [
+        {
+          "path": "/_query_rules/{ruleset_id}/_test",
+          "methods": [
+            "POST"
+          ],
+          "parts": {
+            "ruleset_id": {
+              "type": "string",
+              "description": "The unique identifier of the ruleset to test."
+            }
+          }
+        }
+      ]
+    },
+    "body": {
+      "description": "The match criteria to test against the ruleset",
+      "required": true
+    }
+  }
+}

+ 1 - 0
server/src/main/java/org/elasticsearch/TransportVersions.java

@@ -242,6 +242,7 @@ public class TransportVersions {
     public static final TransportVersion ESQL_CACHED_STRING_SERIALIZATION = def(8_766_00_0);
     public static final TransportVersion CHUNK_SENTENCE_OVERLAP_SETTING_ADDED = def(8_767_00_0);
     public static final TransportVersion OPT_IN_ESQL_CCS_EXECUTION_INFO = def(8_768_00_0);
+    public static final TransportVersion QUERY_RULE_TEST_API = def(8_769_00_0);
 
     /*
      * STOP! READ THIS FIRST! No, really,

+ 252 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/rules/70_query_rule_test.yml

@@ -0,0 +1,252 @@
+setup:
+  - requires:
+      cluster_features: [ "query_rules.test" ]
+      reason: Introduced in 8.16.0
+
+  - do:
+      query_rules.put_ruleset:
+        ruleset_id: test-ruleset
+        body:
+          rules:
+            - rule_id: rule1
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  values: [ search ]
+              actions:
+                ids:
+                  - 'doc1'
+            - rule_id: rule2
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  values: [ ui ]
+              actions:
+                docs:
+                  - '_index': 'test-index1'
+                    '_id': 'doc2'
+            - rule_id: rule3
+              type: pinned
+              criteria:
+                - type: contains
+                  metadata: query_string
+                  values: [ kibana, logstash ]
+              actions:
+                ids:
+                  - 'doc2'
+                  - 'doc3'
+            - rule_id: rule4
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  values: [ ops ]
+              actions:
+                ids:
+                  - 'doc7'
+            - rule_id: rule5
+              type: exclude
+              criteria:
+                - type: exact
+                  metadata: query_string
+                  values: [ search ]
+              actions:
+                ids:
+                  - 'doc8'
+
+---
+teardown:
+  - do:
+      query_rules.delete_ruleset:
+        ruleset_id: test-ruleset
+        ignore: 404
+
+  - do:
+      query_rules.delete_ruleset:
+        ruleset_id: combined-ruleset
+        ignore: 404
+
+  - do:
+      query_rules.delete_ruleset:
+        ruleset_id: double-jeopardy-ruleset
+        ignore: 404
+
+---
+"Test query rules, specifying a ruleset that does not exist":
+  - do:
+      catch: /resource_not_found_exception/
+      query_rules.test:
+        ruleset_id: nonexistent-ruleset
+        body:
+          match_criteria:
+            foo: bar
+
+
+---
+"Test query rules with an empty body":
+  - do:
+      catch: bad_request
+      query_rules.test:
+        ruleset_id: nonexistent-ruleset
+        body: { }
+
+---
+"Test query rules with an ID match":
+
+  - do:
+      query_rules.test:
+        ruleset_id: test-ruleset
+        body:
+          match_criteria:
+            query_string: search
+
+  - match: { total_matched_rules: 2 }
+  - match: { matched_rules.0.rule_id: 'rule1' }
+  - match: { matched_rules.1.rule_id: 'rule5' }
+
+---
+"As a user, test query rules with an ID match":
+  - skip:
+      features: headers
+
+  - do:
+      catch: forbidden
+      headers: { Authorization: "Basic ZW50c2VhcmNoLXVzZXI6ZW50c2VhcmNoLXVzZXItcGFzc3dvcmQ=" }  # user
+      query_rules.test:
+        ruleset_id: test-ruleset
+        body:
+          match_criteria:
+            query_string: search
+
+---
+"Test query rules with a doc match":
+
+  - do:
+      query_rules.test:
+        ruleset_id: test-ruleset
+        body:
+          match_criteria:
+            query_string: ui
+
+  - match: { total_matched_rules: 1 }
+  - match: { matched_rules.0.rule_id: 'rule2' }
+
+---
+"As a user, test query rules with a doc match":
+  - skip:
+      features: headers
+
+  - do:
+      catch: forbidden
+      headers: { Authorization: "Basic ZW50c2VhcmNoLXVzZXI6ZW50c2VhcmNoLXVzZXItcGFzc3dvcmQ=" }  # user
+      query_rules.test:
+        ruleset_id: test-ruleset
+        body:
+          match_criteria:
+            query_string: ui
+
+---
+"Test query rules with no matching rules":
+
+  - do:
+      query_rules.test:
+        ruleset_id: test-ruleset
+        body:
+          match_criteria:
+            query_string: no-match
+
+  - match: { total_matched_rules: 0 }
+
+---
+"Test rules where the same ID is both pinned and excluded":
+  - do:
+      query_rules.put_ruleset:
+        ruleset_id: double-jeopardy-ruleset
+        body:
+          rules:
+            - rule_id: rule1
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: foo
+                  values: [ bar ]
+              actions:
+                ids:
+                  - 'doc8'
+            - rule_id: rule2
+              type: exclude
+              criteria:
+                - type: exact
+                  metadata: foo
+                  values: [ bar ]
+              actions:
+                ids:
+                  - 'doc8'
+
+  - do:
+      query_rules.test:
+        ruleset_id: double-jeopardy-ruleset
+        body:
+          match_criteria:
+            foo: bar
+
+  - match: { total_matched_rules: 2 }
+  - match: { matched_rules.0.rule_id: 'rule1' }
+  - match: { matched_rules.1.rule_id: 'rule2' }
+
+---
+"Perform a rule query over a ruleset with combined numeric and text rule matching":
+
+  - do:
+      query_rules.put_ruleset:
+        ruleset_id: combined-ruleset
+        body:
+          rules:
+            - rule_id: rule1
+              type: pinned
+              criteria:
+                - type: exact
+                  metadata: foo
+                  values: [ bar ]
+              actions:
+                ids:
+                  - 'doc1'
+            - rule_id: rule2
+              type: pinned
+              criteria:
+                - type: lte
+                  metadata: foo
+                  values: [ 100 ]
+              actions:
+                ids:
+                  - 'doc2'
+  - do:
+      query_rules.test:
+        ruleset_id: combined-ruleset
+        body:
+          match_criteria:
+            foo: 100
+
+  - match: { total_matched_rules: 1 }
+  - match: { matched_rules.0.rule_id: 'rule2' }
+
+  - do:
+      query_rules.test:
+        ruleset_id: combined-ruleset
+        body:
+          match_criteria:
+            foo: bar
+
+  - match: { total_matched_rules: 1 }
+  - match: { matched_rules.0.rule_id: 'rule1' }
+
+  - do:
+      query_rules.test:
+        ruleset_id: combined-ruleset
+        body:
+          match_criteria:
+            foo: baz
+
+  - match: { total_matched_rules: 0 }

+ 6 - 1
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearch.java

@@ -165,6 +165,8 @@ import org.elasticsearch.xpack.application.rules.action.RestGetQueryRulesetActio
 import org.elasticsearch.xpack.application.rules.action.RestListQueryRulesetsAction;
 import org.elasticsearch.xpack.application.rules.action.RestPutQueryRuleAction;
 import org.elasticsearch.xpack.application.rules.action.RestPutQueryRulesetAction;
+import org.elasticsearch.xpack.application.rules.action.RestTestQueryRulesetAction;
+import org.elasticsearch.xpack.application.rules.action.TestQueryRulesetAction;
 import org.elasticsearch.xpack.application.rules.action.TransportDeleteQueryRuleAction;
 import org.elasticsearch.xpack.application.rules.action.TransportDeleteQueryRulesetAction;
 import org.elasticsearch.xpack.application.rules.action.TransportGetQueryRuleAction;
@@ -172,6 +174,7 @@ import org.elasticsearch.xpack.application.rules.action.TransportGetQueryRuleset
 import org.elasticsearch.xpack.application.rules.action.TransportListQueryRulesetsAction;
 import org.elasticsearch.xpack.application.rules.action.TransportPutQueryRuleAction;
 import org.elasticsearch.xpack.application.rules.action.TransportPutQueryRulesetAction;
+import org.elasticsearch.xpack.application.rules.action.TransportTestQueryRulesetAction;
 import org.elasticsearch.xpack.application.search.SearchApplicationIndexService;
 import org.elasticsearch.xpack.application.search.action.DeleteSearchApplicationAction;
 import org.elasticsearch.xpack.application.search.action.GetSearchApplicationAction;
@@ -266,6 +269,7 @@ public class EnterpriseSearch extends Plugin implements ActionPlugin, SystemInde
                 new ActionHandler<>(DeleteQueryRuleAction.INSTANCE, TransportDeleteQueryRuleAction.class),
                 new ActionHandler<>(GetQueryRuleAction.INSTANCE, TransportGetQueryRuleAction.class),
                 new ActionHandler<>(PutQueryRuleAction.INSTANCE, TransportPutQueryRuleAction.class),
+                new ActionHandler<>(TestQueryRulesetAction.INSTANCE, TransportTestQueryRulesetAction.class),
 
                 usageAction,
                 infoAction
@@ -373,7 +377,8 @@ public class EnterpriseSearch extends Plugin implements ActionPlugin, SystemInde
                 new RestPutQueryRulesetAction(getLicenseState()),
                 new RestDeleteQueryRuleAction(getLicenseState()),
                 new RestGetQueryRuleAction(getLicenseState()),
-                new RestPutQueryRuleAction(getLicenseState())
+                new RestPutQueryRuleAction(getLicenseState()),
+                new RestTestQueryRulesetAction(getLicenseState())
             )
         );
 

+ 9 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearchFeatures.java

@@ -14,8 +14,17 @@ import org.elasticsearch.xpack.application.analytics.AnalyticsTemplateRegistry;
 import org.elasticsearch.xpack.application.connector.ConnectorTemplateRegistry;
 
 import java.util.Map;
+import java.util.Set;
+
+import static org.elasticsearch.xpack.application.rules.action.TestQueryRulesetAction.QUERY_RULES_TEST_API;
 
 public class EnterpriseSearchFeatures implements FeatureSpecification {
+
+    @Override
+    public Set<NodeFeature> getFeatures() {
+        return Set.of(QUERY_RULES_TEST_API);
+    }
+
     @Override
     public Map<NodeFeature, Version> getHistoricalFeatures() {
         return Map.of(

+ 7 - 6
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/QueryRule.java

@@ -331,12 +331,8 @@ public class QueryRule implements Writeable, ToXContentObject {
         return new AppliedQueryRules(pinnedDocs, excludedDocs);
     }
 
-    @SuppressWarnings("unchecked")
-    private List<SpecifiedDocument> identifyMatchingDocs(Map<String, Object> matchCriteria) {
-        List<SpecifiedDocument> matchingDocs = new ArrayList<>();
+    public boolean isRuleMatch(Map<String, Object> matchCriteria) {
         Boolean isRuleMatch = null;
-
-        // All specified criteria in a rule must match for the rule to be applied
         for (QueryRuleCriteria criterion : criteria) {
             for (String match : matchCriteria.keySet()) {
                 final Object matchValue = matchCriteria.get(match);
@@ -349,8 +345,13 @@ public class QueryRule implements Writeable, ToXContentObject {
                 }
             }
         }
+        return isRuleMatch != null && isRuleMatch;
+    }
 
-        if (isRuleMatch != null && isRuleMatch) {
+    @SuppressWarnings("unchecked")
+    private List<SpecifiedDocument> identifyMatchingDocs(Map<String, Object> matchCriteria) {
+        List<SpecifiedDocument> matchingDocs = new ArrayList<>();
+        if (isRuleMatch(matchCriteria)) {
             if (actions.containsKey(IDS_FIELD.getPreferredName())) {
                 matchingDocs.addAll(
                     ((List<String>) actions.get(IDS_FIELD.getPreferredName())).stream().map(id -> new SpecifiedDocument(null, id)).toList()

+ 2 - 1
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/GetQueryRulesetAction.java

@@ -31,7 +31,8 @@ import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg
 
 public class GetQueryRulesetAction {
 
-    public static final String NAME = "cluster:admin/xpack/query_rules/get";
+    public static final ActionType<GetQueryRulesetAction.Response> TYPE = new ActionType<>("cluster:admin/xpack/query_rules/get");
+    public static final String NAME = TYPE.name();
     public static final ActionType<GetQueryRulesetAction.Response> INSTANCE = new ActionType<>(NAME);
 
     private GetQueryRulesetAction() {/* no instances */}

+ 53 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/RestTestQueryRulesetAction.java

@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.rules.action;
+
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.Scope;
+import org.elasticsearch.rest.ServerlessScope;
+import org.elasticsearch.rest.action.RestToXContentListener;
+import org.elasticsearch.xpack.application.EnterpriseSearch;
+import org.elasticsearch.xpack.application.EnterpriseSearchBaseRestHandler;
+import org.elasticsearch.xpack.application.utils.LicenseUtils;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.elasticsearch.rest.RestRequest.Method.POST;
+
+@ServerlessScope(Scope.PUBLIC)
+public class RestTestQueryRulesetAction extends EnterpriseSearchBaseRestHandler {
+    public RestTestQueryRulesetAction(XPackLicenseState licenseState) {
+        super(licenseState, LicenseUtils.Product.QUERY_RULES);
+    }
+
+    @Override
+    public String getName() {
+        return "query_ruleset_test_action";
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(POST, "/" + EnterpriseSearch.QUERY_RULES_API_ENDPOINT + "/{ruleset_id}" + "/_test"));
+    }
+
+    @Override
+    protected RestChannelConsumer innerPrepareRequest(RestRequest restRequest, NodeClient client) throws IOException {
+        final String rulesetId = restRequest.param("ruleset_id");
+        TestQueryRulesetAction.Request request = null;
+        if (restRequest.hasContent()) {
+            try (var parser = restRequest.contentParser()) {
+                request = TestQueryRulesetAction.Request.parse(parser, rulesetId);
+            }
+        }
+        final TestQueryRulesetAction.Request finalRequest = request;
+        return channel -> client.execute(TestQueryRulesetAction.INSTANCE, finalRequest, new RestToXContentListener<>(channel));
+    }
+}

+ 212 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetAction.java

@@ -0,0 +1,212 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.rules.action;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.action.IndicesRequest;
+import org.elasticsearch.action.support.IndicesOptions;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.features.NodeFeature;
+import org.elasticsearch.xcontent.ConstructingObjectParser;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xpack.application.rules.QueryRulesIndexService;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg;
+import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg;
+
+public class TestQueryRulesetAction {
+
+    public static final NodeFeature QUERY_RULES_TEST_API = new NodeFeature("query_rules.test");
+
+    // TODO - We'd like to transition this to require less stringent permissions
+    public static final ActionType<TestQueryRulesetAction.Response> TYPE = new ActionType<>("cluster:admin/xpack/query_rules/test");
+
+    public static final String NAME = TYPE.name();
+    public static final ActionType<TestQueryRulesetAction.Response> INSTANCE = new ActionType<>(NAME);
+
+    private TestQueryRulesetAction() {/* no instances */}
+
+    public static class Request extends ActionRequest implements ToXContentObject, IndicesRequest {
+        private final String rulesetId;
+        private final Map<String, Object> matchCriteria;
+
+        private static final ParseField RULESET_ID_FIELD = new ParseField("ruleset_id");
+        private static final ParseField MATCH_CRITERIA_FIELD = new ParseField("match_criteria");
+
+        public Request(StreamInput in) throws IOException {
+            super(in);
+            this.rulesetId = in.readString();
+            this.matchCriteria = in.readGenericMap();
+        }
+
+        public Request(String rulesetId, Map<String, Object> matchCriteria) {
+            this.rulesetId = rulesetId;
+            this.matchCriteria = matchCriteria;
+        }
+
+        @Override
+        public ActionRequestValidationException validate() {
+            ActionRequestValidationException validationException = null;
+
+            if (Strings.isNullOrEmpty(rulesetId)) {
+                validationException = addValidationError("ruleset_id missing", validationException);
+            }
+
+            return validationException;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            out.writeString(rulesetId);
+            out.writeGenericMap(matchCriteria);
+        }
+
+        public String rulesetId() {
+            return rulesetId;
+        }
+
+        public Map<String, Object> matchCriteria() {
+            return matchCriteria;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Request request = (Request) o;
+            return Objects.equals(rulesetId, request.rulesetId) && Objects.equals(matchCriteria, request.matchCriteria);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(rulesetId, matchCriteria);
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            builder.startObject();
+            builder.field(RULESET_ID_FIELD.getPreferredName(), rulesetId);
+            builder.startObject(MATCH_CRITERIA_FIELD.getPreferredName());
+            builder.mapContents(matchCriteria);
+            builder.endObject();
+            builder.endObject();
+            return builder;
+        }
+
+        private static final ConstructingObjectParser<Request, String> PARSER = new ConstructingObjectParser<>(
+            "test_query_ruleset_request",
+            false,
+            (p, name) -> {
+                @SuppressWarnings("unchecked")
+                Map<String, Object> matchCriteria = (Map<String, Object>) p[0];
+                return new Request(name, matchCriteria);
+            }
+
+        );
+        static {
+            PARSER.declareObject(constructorArg(), (p, c) -> p.map(), MATCH_CRITERIA_FIELD);
+            PARSER.declareString(optionalConstructorArg(), RULESET_ID_FIELD); // Required for parsing
+        }
+
+        public static Request parse(XContentParser parser, String name) {
+            return PARSER.apply(parser, name);
+        }
+
+        @Override
+        public String[] indices() {
+            return new String[] { QueryRulesIndexService.QUERY_RULES_ALIAS_NAME };
+        }
+
+        @Override
+        public IndicesOptions indicesOptions() {
+            return IndicesOptions.lenientExpandHidden();
+        }
+
+    }
+
+    public static class Response extends ActionResponse implements ToXContentObject {
+
+        private final int totalMatchedRules;
+        private final List<MatchedRule> matchedRules;
+
+        private static final ParseField TOTAL_MATCHED_RULES_FIELD = new ParseField("total_matched_rules");
+        private static final ParseField MATCHED_RULES_FIELD = new ParseField("matched_rules");
+
+        public Response(StreamInput in) throws IOException {
+            super(in);
+            this.totalMatchedRules = in.readVInt();
+            this.matchedRules = in.readCollectionAsList(MatchedRule::new);
+        }
+
+        public Response(int totalMatchedRules, List<MatchedRule> matchedRules) {
+            this.totalMatchedRules = totalMatchedRules;
+            this.matchedRules = matchedRules;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            out.writeVInt(totalMatchedRules);
+            out.writeCollection(matchedRules, (stream, matchedRule) -> matchedRule.writeTo(stream));
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            builder.startObject();
+            builder.field(TOTAL_MATCHED_RULES_FIELD.getPreferredName(), totalMatchedRules);
+            builder.startArray(MATCHED_RULES_FIELD.getPreferredName());
+            for (MatchedRule matchedRule : matchedRules) {
+                builder.startObject();
+                builder.field("ruleset_id", matchedRule.rulesetId());
+                builder.field("rule_id", matchedRule.ruleId());
+                builder.endObject();
+            }
+            builder.endArray();
+            builder.endObject();
+            return builder;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Response response = (Response) o;
+            return Objects.equals(totalMatchedRules, response.totalMatchedRules) && Objects.equals(matchedRules, response.matchedRules);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(totalMatchedRules, matchedRules);
+        }
+    }
+
+    public record MatchedRule(String rulesetId, String ruleId) {
+        public MatchedRule(StreamInput in) throws IOException {
+            this(in.readString(), in.readString());
+        }
+
+        public void writeTo(StreamOutput out) throws IOException {
+            out.writeString(rulesetId);
+            out.writeString(ruleId);
+        }
+    }
+}

+ 64 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/rules/action/TransportTestQueryRulesetAction.java

@@ -0,0 +1,64 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.rules.action;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.HandledTransportAction;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.common.util.concurrent.EsExecutors;
+import org.elasticsearch.injection.guice.Inject;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.rules.QueryRule;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.elasticsearch.xpack.core.ClientHelper.ENT_SEARCH_ORIGIN;
+import static org.elasticsearch.xpack.core.ClientHelper.executeAsyncWithOrigin;
+
+public class TransportTestQueryRulesetAction extends HandledTransportAction<
+    TestQueryRulesetAction.Request,
+    TestQueryRulesetAction.Response> {
+
+    private final Client client;
+
+    @Inject
+    public TransportTestQueryRulesetAction(TransportService transportService, ActionFilters actionFilters, Client client) {
+        super(
+            TestQueryRulesetAction.NAME,
+            transportService,
+            actionFilters,
+            TestQueryRulesetAction.Request::new,
+            EsExecutors.DIRECT_EXECUTOR_SERVICE
+        );
+        this.client = client;
+    }
+
+    @Override
+    protected void doExecute(Task task, TestQueryRulesetAction.Request request, ActionListener<TestQueryRulesetAction.Response> listener) {
+        GetQueryRulesetAction.Request getQueryRulesetRequest = new GetQueryRulesetAction.Request(request.rulesetId());
+        executeAsyncWithOrigin(
+            client,
+            ENT_SEARCH_ORIGIN,
+            GetQueryRulesetAction.TYPE,
+            getQueryRulesetRequest,
+            ActionListener.wrap(getQueryRulesetResponse -> {
+                List<TestQueryRulesetAction.MatchedRule> matchedRules = new ArrayList<>();
+                for (QueryRule rule : getQueryRulesetResponse.queryRuleset().rules()) {
+                    if (rule.isRuleMatch(request.matchCriteria())) {
+                        matchedRules.add(new TestQueryRulesetAction.MatchedRule(request.rulesetId(), rule.id()));
+                    }
+                }
+                listener.onResponse(new TestQueryRulesetAction.Response(matchedRules.size(), matchedRules));
+            }, listener::onFailure)
+        );
+    }
+
+}

+ 4 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/EnterpriseSearchModuleTestUtils.java

@@ -115,4 +115,8 @@ public final class EnterpriseSearchModuleTestUtils {
         return new QueryRuleset(id, rules);
     }
 
+    public static Map<String, Object> randomMatchCriteria() {
+        return randomMap(1, 3, () -> Tuple.tuple(randomIdentifier(), randomAlphaOfLengthBetween(0, 10)));
+    }
+
 }

+ 53 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/RestTestQueryRulesetActionTests.java

@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.rules.action;
+
+import org.elasticsearch.common.bytes.BytesArray;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.test.rest.FakeRestRequest;
+import org.elasticsearch.xcontent.NamedXContentRegistry;
+import org.elasticsearch.xcontent.XContentType;
+import org.elasticsearch.xpack.application.AbstractRestEnterpriseSearchActionTests;
+import org.elasticsearch.xpack.application.EnterpriseSearchBaseRestHandler;
+import org.elasticsearch.xpack.application.utils.LicenseUtils;
+
+import java.util.Map;
+
+public class RestTestQueryRulesetActionTests extends AbstractRestEnterpriseSearchActionTests {
+    public void testWithNonCompliantLicense() throws Exception {
+        checkLicenseForRequest(
+            new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withMethod(RestRequest.Method.POST)
+                .withParams(Map.of("ruleset_id", "ruleset-id"))
+                .withContent(new BytesArray("""
+                    {
+                      "match_criteria": {
+                        "foo": "bar"
+                      }
+                    }
+                    """), XContentType.JSON)
+                .build(),
+            LicenseUtils.Product.QUERY_RULES
+        );
+    }
+
+    public void testInvalidRequestWithNonCompliantLicense() throws Exception {
+        checkLicenseForRequest(
+            new FakeRestRequest.Builder(NamedXContentRegistry.EMPTY).withMethod(RestRequest.Method.POST)
+                .withParams(Map.of("invalid_param_name", "invalid_value"))
+                .withContent(new BytesArray("{}"), XContentType.JSON)
+                .build(),
+            LicenseUtils.Product.QUERY_RULES
+        );
+    }
+
+    @Override
+    protected EnterpriseSearchBaseRestHandler getRestAction(XPackLicenseState licenseState) {
+        return new RestTestQueryRulesetAction(licenseState);
+    }
+}

+ 56 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetActionRequestBWCSerializingTests.java

@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.rules.action;
+
+import org.elasticsearch.TransportVersion;
+import org.elasticsearch.TransportVersions;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractBWCSerializationTestCase;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xpack.application.EnterpriseSearchModuleTestUtils;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.elasticsearch.test.BWCVersions.getAllBWCVersions;
+
+public class TestQueryRulesetActionRequestBWCSerializingTests extends AbstractBWCSerializationTestCase<TestQueryRulesetAction.Request> {
+
+    private final String RULESET_NAME = "my-ruleset";
+
+    @Override
+    protected Writeable.Reader<TestQueryRulesetAction.Request> instanceReader() {
+        return TestQueryRulesetAction.Request::new;
+    }
+
+    @Override
+    protected TestQueryRulesetAction.Request createTestInstance() {
+        return new TestQueryRulesetAction.Request(RULESET_NAME, EnterpriseSearchModuleTestUtils.randomMatchCriteria());
+    }
+
+    @Override
+    protected TestQueryRulesetAction.Request mutateInstance(TestQueryRulesetAction.Request instance) {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+
+    @Override
+    protected TestQueryRulesetAction.Request doParseInstance(XContentParser parser) throws IOException {
+        return TestQueryRulesetAction.Request.parse(parser, RULESET_NAME);
+    }
+
+    @Override
+    protected TestQueryRulesetAction.Request mutateInstanceForVersion(TestQueryRulesetAction.Request instance, TransportVersion version) {
+        return instance;
+    }
+
+    @Override
+    protected List<TransportVersion> bwcVersions() {
+        return getAllBWCVersions().stream().filter(v -> v.onOrAfter(TransportVersions.QUERY_RULE_TEST_API)).collect(Collectors.toList());
+    }
+}

+ 52 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/rules/action/TestQueryRulesetActionResponseBWCSerializingTests.java

@@ -0,0 +1,52 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.rules.action;
+
+import org.elasticsearch.TransportVersion;
+import org.elasticsearch.TransportVersions;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.xpack.core.ml.AbstractBWCWireSerializationTestCase;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.elasticsearch.test.BWCVersions.getAllBWCVersions;
+
+public class TestQueryRulesetActionResponseBWCSerializingTests extends AbstractBWCWireSerializationTestCase<
+    TestQueryRulesetAction.Response> {
+
+    @Override
+    protected Writeable.Reader<TestQueryRulesetAction.Response> instanceReader() {
+        return TestQueryRulesetAction.Response::new;
+    }
+
+    @Override
+    protected TestQueryRulesetAction.Response mutateInstance(TestQueryRulesetAction.Response instance) {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+
+    @Override
+    protected TestQueryRulesetAction.Response createTestInstance() {
+        int totalMatchedRules = randomIntBetween(0, 10);
+        List<TestQueryRulesetAction.MatchedRule> matchedRules = IntStream.range(0, totalMatchedRules)
+            .mapToObj(i -> new TestQueryRulesetAction.MatchedRule(randomAlphaOfLengthBetween(5, 10), randomAlphaOfLengthBetween(5, 10)))
+            .toList();
+        return new TestQueryRulesetAction.Response(totalMatchedRules, matchedRules);
+    }
+
+    @Override
+    protected TestQueryRulesetAction.Response mutateInstanceForVersion(TestQueryRulesetAction.Response instance, TransportVersion version) {
+        return instance;
+    }
+
+    @Override
+    protected List<TransportVersion> bwcVersions() {
+        return getAllBWCVersions().stream().filter(v -> v.onOrAfter(TransportVersions.QUERY_RULE_TEST_API)).collect(Collectors.toList());
+    }
+}

+ 1 - 0
x-pack/plugin/ml/qa/native-multi-node-tests/build.gradle

@@ -18,6 +18,7 @@ dependencies {
   javaRestTestImplementation project(path: xpackModule('esql-core'))
   javaRestTestImplementation project(path: xpackModule('esql'))
   javaRestTestImplementation project(path: xpackModule('snapshot-repo-test-kit'))
+  javaRestTestImplementation project(path: xpackModule('ent-search'))
 }
 
 // location for keys and certificates

+ 1 - 0
x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java

@@ -245,6 +245,7 @@ public class Constants {
         "cluster:admin/xpack/query_rules/get",
         "cluster:admin/xpack/query_rules/list",
         "cluster:admin/xpack/query_rules/put",
+        "cluster:admin/xpack/query_rules/test",
         "cluster:admin/xpack/rollup/delete",
         "cluster:admin/xpack/rollup/put",
         "cluster:admin/xpack/rollup/start",