Browse Source

Expose capability checks for YAML REST tests (#108425)

Co-authored-by: Simon Cooper <simon.cooper@elastic.co>
Moritz Mack 1 year ago
parent
commit
d2d1357a33

+ 47 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/capabilities.json

@@ -0,0 +1,47 @@
+{
+  "capabilities": {
+    "documentation": {
+      "url": "https://www.elastic.co/guide/en/elasticsearch/reference/master/capabilities.html",
+      "description": "Checks if the specified combination of method, API, parameters, and arbitrary capabilities are supported"
+    },
+    "stability": "experimental",
+    "visibility": "private",
+    "headers": {
+      "accept": [
+        "application/json"
+      ]
+    },
+    "url": {
+      "paths": [
+        {
+          "path": "/_capabilities",
+          "methods": [
+            "GET"
+          ]
+        }
+      ]
+    },
+    "params": {
+      "method": {
+        "type": "enum",
+        "description": "REST method to check",
+        "options": [
+          "GET", "HEAD", "POST", "PUT", "DELETE"
+        ],
+        "default": "GET"
+      },
+      "path": {
+        "type": "string",
+        "description": "API path to check"
+      },
+      "parameters": {
+        "type": "string",
+        "description": "Comma-separated list of API parameters to check"
+      },
+      "capabilities": {
+        "type": "string",
+        "description": "Comma-separated list of arbitrary API capabilities to check"
+      }
+    }
+  }
+}

+ 28 - 0
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/capabilities/10_basic.yml

@@ -0,0 +1,28 @@
+---
+"Capabilities API":
+
+  - requires:
+      capabilities:
+        - method: GET
+          path: /_capabilities
+          parameters: [method, path, parameters, capabilities]
+          capabilities: []
+      reason: "capabilities api requires itself to be supported"
+
+  - do:
+      capabilities:
+        method: GET
+        path: /_capabilities
+        parameters: method,path,parameters,capabilities
+        error_trace: false
+
+  - match: { supported: true }
+
+  - do:
+      capabilities:
+        method: GET
+        path: /_capabilities
+        parameters: unknown
+        error_trace: false
+
+  - match: { supported: false }

+ 5 - 5
server/src/internalClusterTest/java/org/elasticsearch/nodescapabilities/SimpleNodesCapabilitiesIT.java

@@ -15,8 +15,8 @@ import org.elasticsearch.test.ESIntegTestCase;
 
 import java.io.IOException;
 
+import static org.elasticsearch.test.hamcrest.OptionalMatchers.isPresentWith;
 import static org.hamcrest.Matchers.hasSize;
-import static org.hamcrest.Matchers.is;
 
 @ESIntegTestCase.ClusterScope(scope = ESIntegTestCase.Scope.TEST, numDataNodes = 0)
 public class SimpleNodesCapabilitiesIT extends ESIntegTestCase {
@@ -31,25 +31,25 @@ public class SimpleNodesCapabilitiesIT extends ESIntegTestCase {
         NodesCapabilitiesResponse response = clusterAdmin().nodesCapabilities(new NodesCapabilitiesRequest().path("_capabilities"))
             .actionGet();
         assertThat(response.getNodes(), hasSize(2));
-        assertThat(response.isSupported(), is(true));
+        assertThat(response.isSupported(), isPresentWith(true));
 
         // check we support some parameters of the capabilities API
         response = clusterAdmin().nodesCapabilities(new NodesCapabilitiesRequest().path("_capabilities").parameters("method", "path"))
             .actionGet();
         assertThat(response.getNodes(), hasSize(2));
-        assertThat(response.isSupported(), is(true));
+        assertThat(response.isSupported(), isPresentWith(true));
 
         // check we don't support some other parameters of the capabilities API
         response = clusterAdmin().nodesCapabilities(new NodesCapabilitiesRequest().path("_capabilities").parameters("method", "invalid"))
             .actionGet();
         assertThat(response.getNodes(), hasSize(2));
-        assertThat(response.isSupported(), is(false));
+        assertThat(response.isSupported(), isPresentWith(false));
 
         // check we don't support a random invalid api
         // TODO this is not working yet - see https://github.com/elastic/elasticsearch/issues/107425
         /*response = clusterAdmin().nodesCapabilities(new NodesCapabilitiesRequest().path("_invalid"))
             .actionGet();
         assertThat(response.getNodes(), hasSize(2));
-        assertThat(response.isSupported(), is(false));*/
+        assertThat(response.isSupported(), isPresentWith(false));*/
     }
 }

+ 7 - 3
server/src/main/java/org/elasticsearch/action/admin/cluster/node/capabilities/NodesCapabilitiesResponse.java

@@ -19,6 +19,7 @@ import org.elasticsearch.xcontent.XContentBuilder;
 
 import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
 
 public class NodesCapabilitiesResponse extends BaseNodesResponse<NodeCapability> implements ToXContentFragment {
     protected NodesCapabilitiesResponse(ClusterName clusterName, List<NodeCapability> nodes, List<FailedNodeException> failures) {
@@ -35,12 +36,15 @@ public class NodesCapabilitiesResponse extends BaseNodesResponse<NodeCapability>
         TransportAction.localOnly();
     }
 
-    public boolean isSupported() {
-        return getNodes().isEmpty() == false && getNodes().stream().allMatch(NodeCapability::isSupported);
+    public Optional<Boolean> isSupported() {
+        // if there are any failures, we don't know if it is fully supported by all nodes in the cluster
+        if (hasFailures() || getNodes().isEmpty()) return Optional.empty();
+        return Optional.of(getNodes().stream().allMatch(NodeCapability::isSupported));
     }
 
     @Override
     public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
-        return builder.field("supported", isSupported());
+        Optional<Boolean> supported = isSupported();
+        return builder.field("supported", supported.orElse(null));
     }
 }

+ 42 - 1
test/yaml-rest-runner/src/main/java/org/elasticsearch/test/rest/yaml/ClientYamlTestExecutionContext.java

@@ -16,7 +16,9 @@ import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 import org.apache.lucene.util.BytesRef;
 import org.elasticsearch.client.NodeSelector;
+import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.util.Maps;
 import org.elasticsearch.test.rest.Stash;
 import org.elasticsearch.test.rest.TestFeatureService;
 import org.elasticsearch.test.rest.yaml.restspec.ClientYamlSuiteRestApi;
@@ -25,14 +27,19 @@ import org.elasticsearch.xcontent.XContentFactory;
 import org.elasticsearch.xcontent.XContentType;
 
 import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.function.BiPredicate;
 
+import static java.util.Collections.emptyList;
+import static java.util.Collections.emptyMap;
+
 /**
  * Execution context passed across the REST tests.
  * Holds the REST client used to communicate with elasticsearch.
@@ -122,7 +129,15 @@ public class ClientYamlTestExecutionContext {
     ) throws IOException {
         // makes a copy of the parameters before modifying them for this specific request
         Map<String, String> requestParams = new HashMap<>(params);
-        requestParams.putIfAbsent("error_trace", "true"); // By default ask for error traces, this my be overridden by params
+        requestParams.compute("error_trace", (k, v) -> {
+            if (v == null) {
+                return "true";  // By default ask for error traces, this my be overridden by params
+            } else if (v.equals("false")) {
+                return null;
+            } else {
+                return v;
+            }
+        });
         for (Map.Entry<String, String> entry : requestParams.entrySet()) {
             if (stash.containsStashedValue(entry.getValue())) {
                 entry.setValue(stash.getValue(entry.getValue()).toString());
@@ -264,4 +279,30 @@ public class ClientYamlTestExecutionContext {
     public boolean clusterHasFeature(String featureId) {
         return testFeatureService.clusterHasFeature(featureId);
     }
+
+    public Optional<Boolean> clusterHasCapabilities(String method, String path, String parametersString, String capabilitiesString) {
+        Map<String, String> params = Maps.newMapWithExpectedSize(5);
+        params.put("method", method);
+        params.put("path", path);
+        if (Strings.hasLength(parametersString)) {
+            params.put("parameters", parametersString);
+        }
+        if (Strings.hasLength(capabilitiesString)) {
+            params.put("capabilities", capabilitiesString);
+        }
+        params.put("error_trace", "false"); // disable error trace
+        try {
+            ClientYamlTestResponse resp = callApi("capabilities", params, emptyList(), emptyMap());
+            // anything other than 200 should result in an exception, handled below
+            assert resp.getStatusCode() == 200 : "Unknown response code " + resp.getStatusCode();
+            return Optional.ofNullable(resp.evaluate("supported"));
+        } catch (ClientYamlTestResponseException responseException) {
+            if (responseException.getRestTestResponse().getStatusCode() / 100 == 4) {
+                return Optional.empty(); // we don't know, the capabilities API is unsupported
+            }
+            throw new UncheckedIOException(responseException);
+        } catch (IOException ioException) {
+            throw new UncheckedIOException(ioException);
+        }
+    }
 }

+ 78 - 12
test/yaml-rest-runner/src/main/java/org/elasticsearch/test/rest/yaml/section/PrerequisiteSection.java

@@ -19,6 +19,7 @@ import org.junit.AssumptionViolatedException;
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -27,6 +28,7 @@ import java.util.function.Consumer;
 import java.util.function.Predicate;
 
 import static java.util.Collections.emptyList;
+import static java.util.stream.Collectors.joining;
 
 /**
  * Represents a section where prerequisites to run a specific test section or suite are specified. It is possible to specify preconditions
@@ -43,16 +45,23 @@ public class PrerequisiteSection {
         private static final Set<String> FIELD_NAMES = Set.of("cluster_feature", "fixed_by");
     }
 
+    record CapabilitiesCheck(String method, String path, String parameters, String capabilities) {
+        private static final Set<String> FIELD_NAMES = Set.of("method", "path", "parameters", "capabilities");
+    }
+
     static class PrerequisiteSectionBuilder {
-        String skipVersionRange = null;
         String skipReason = null;
-        String requiresReason = null;
-        List<String> requiredYamlRunnerFeatures = new ArrayList<>();
+        String skipVersionRange = null;
         List<String> skipOperatingSystems = new ArrayList<>();
         List<KnownIssue> skipKnownIssues = new ArrayList<>();
         String skipAwaitsFix = null;
         Set<String> skipClusterFeatures = new HashSet<>();
+        List<CapabilitiesCheck> skipCapabilities = new ArrayList<>();
+
+        String requiresReason = null;
+        List<String> requiredYamlRunnerFeatures = new ArrayList<>();
         Set<String> requiredClusterFeatures = new HashSet<>();
+        List<CapabilitiesCheck> requiredCapabilities = new ArrayList<>();
 
         enum XPackRequired {
             NOT_SPECIFIED,
@@ -116,11 +125,21 @@ public class PrerequisiteSection {
             return this;
         }
 
+        public PrerequisiteSectionBuilder skipIfCapabilities(CapabilitiesCheck capabilitiesCheck) {
+            skipCapabilities.add(capabilitiesCheck);
+            return this;
+        }
+
         public PrerequisiteSectionBuilder requireClusterFeature(String featureName) {
             requiredClusterFeatures.add(featureName);
             return this;
         }
 
+        public PrerequisiteSectionBuilder requireCapabilities(CapabilitiesCheck capabilitiesCheck) {
+            requiredCapabilities.add(capabilitiesCheck);
+            return this;
+        }
+
         public PrerequisiteSectionBuilder skipIfOs(String osName) {
             this.skipOperatingSystems.add(osName);
             return this;
@@ -128,13 +147,15 @@ public class PrerequisiteSection {
 
         void validate(XContentLocation contentLocation) {
             if ((Strings.isEmpty(skipVersionRange))
-                && requiredYamlRunnerFeatures.isEmpty()
                 && skipOperatingSystems.isEmpty()
-                && xpackRequired == XPackRequired.NOT_SPECIFIED
-                && requiredClusterFeatures.isEmpty()
                 && skipClusterFeatures.isEmpty()
+                && skipCapabilities.isEmpty()
                 && skipKnownIssues.isEmpty()
-                && Strings.isEmpty(skipAwaitsFix)) {
+                && Strings.isEmpty(skipAwaitsFix)
+                && xpackRequired == XPackRequired.NOT_SPECIFIED
+                && requiredYamlRunnerFeatures.isEmpty()
+                && requiredCapabilities.isEmpty()
+                && requiredClusterFeatures.isEmpty()) {
                 // TODO separate the validation for requires / skip when dropping parsing of legacy fields, e.g. features in skip
                 throw new ParsingException(contentLocation, "at least one predicate is mandatory within a skip or requires section");
             }
@@ -143,11 +164,12 @@ public class PrerequisiteSection {
                 && (Strings.isEmpty(skipVersionRange)
                     && skipOperatingSystems.isEmpty()
                     && skipClusterFeatures.isEmpty()
+                    && skipCapabilities.isEmpty()
                     && skipKnownIssues.isEmpty()) == false) {
                 throw new ParsingException(contentLocation, "reason is mandatory within this skip section");
             }
 
-            if (Strings.isEmpty(requiresReason) && (requiredClusterFeatures.isEmpty() == false)) {
+            if (Strings.isEmpty(requiresReason) && ((requiredClusterFeatures.isEmpty() && requiredCapabilities.isEmpty()) == false)) {
                 throw new ParsingException(contentLocation, "reason is mandatory within this requires section");
             }
 
@@ -190,6 +212,13 @@ public class PrerequisiteSection {
             if (xpackRequired == XPackRequired.YES) {
                 requiresCriteriaList.add(Prerequisites.hasXPack());
             }
+            if (requiredClusterFeatures.isEmpty() == false) {
+                requiresCriteriaList.add(Prerequisites.requireClusterFeatures(requiredClusterFeatures));
+            }
+            if (requiredCapabilities.isEmpty() == false) {
+                requiresCriteriaList.add(Prerequisites.requireCapabilities(requiredCapabilities));
+            }
+
             if (xpackRequired == XPackRequired.NO) {
                 skipCriteriaList.add(Prerequisites.hasXPack());
             }
@@ -199,12 +228,12 @@ public class PrerequisiteSection {
             if (skipOperatingSystems.isEmpty() == false) {
                 skipCriteriaList.add(Prerequisites.skipOnOsList(skipOperatingSystems));
             }
-            if (requiredClusterFeatures.isEmpty() == false) {
-                requiresCriteriaList.add(Prerequisites.requireClusterFeatures(requiredClusterFeatures));
-            }
             if (skipClusterFeatures.isEmpty() == false) {
                 skipCriteriaList.add(Prerequisites.skipOnClusterFeatures(skipClusterFeatures));
             }
+            if (skipCapabilities.isEmpty() == false) {
+                skipCriteriaList.add(Prerequisites.skipCapabilities(skipCapabilities));
+            }
             if (skipKnownIssues.isEmpty() == false) {
                 skipCriteriaList.add(Prerequisites.skipOnKnownIssue(skipKnownIssues));
             }
@@ -287,6 +316,7 @@ public class PrerequisiteSection {
                     case "os" -> parseStrings(parser, builder::skipIfOs);
                     case "cluster_features" -> parseStrings(parser, builder::skipIfClusterFeature);
                     case "known_issues" -> parseArray(parser, PrerequisiteSection::parseKnownIssue, builder::skipKnownIssue);
+                    case "capabilities" -> parseArray(parser, PrerequisiteSection::parseCapabilities, builder::skipIfCapabilities);
                     default -> false;
                 };
             }
@@ -337,12 +367,47 @@ public class PrerequisiteSection {
         if (fields.keySet().equals(KnownIssue.FIELD_NAMES) == false) {
             throw new ParsingException(
                 parser.getTokenLocation(),
-                Strings.format("Expected fields %s, but got %s", KnownIssue.FIELD_NAMES, fields.keySet())
+                Strings.format("Expected all of %s, but got %s", KnownIssue.FIELD_NAMES, fields.keySet())
             );
         }
         return new KnownIssue(fields.get("cluster_feature"), fields.get("fixed_by"));
     }
 
+    private static CapabilitiesCheck parseCapabilities(XContentParser parser) throws IOException {
+        Map<String, Object> fields = parser.map();
+        if (CapabilitiesCheck.FIELD_NAMES.containsAll(fields.keySet()) == false) {
+            throw new ParsingException(
+                parser.getTokenLocation(),
+                Strings.format("Expected some of %s, but got %s", CapabilitiesCheck.FIELD_NAMES, fields.keySet())
+            );
+        }
+        Object path = fields.get("path");
+        if (path == null) {
+            throw new ParsingException(parser.getTokenLocation(), "path is required");
+        }
+
+        return new CapabilitiesCheck(
+            ensureString(ensureString(fields.getOrDefault("method", "GET"))),
+            ensureString(path),
+            stringArrayAsParamString("parameters", fields),
+            stringArrayAsParamString("capabilities", fields)
+        );
+    }
+
+    private static String ensureString(Object obj) {
+        if (obj instanceof String str) return str;
+        throw new IllegalArgumentException("Expected STRING, but got: " + obj);
+    }
+
+    private static String stringArrayAsParamString(String name, Map<String, Object> fields) {
+        Object value = fields.get(name);
+        if (value == null) return null;
+        if (value instanceof Collection<?> values) {
+            return values.stream().map(PrerequisiteSection::ensureString).collect(joining(","));
+        }
+        return ensureString(value);
+    }
+
     static void parseRequiresSection(XContentParser parser, PrerequisiteSectionBuilder builder) throws IOException {
         requireStartObject("requires", parser.nextToken());
 
@@ -361,6 +426,7 @@ public class PrerequisiteSection {
                 valid = switch (parser.currentName()) {
                     case "test_runner_features" -> parseStrings(parser, f -> parseFeatureField(f, builder));
                     case "cluster_features" -> parseStrings(parser, builder::requireClusterFeature);
+                    case "capabilities" -> parseArray(parser, PrerequisiteSection::parseCapabilities, builder::requireCapabilities);
                     default -> false;
                 };
             }

+ 19 - 1
test/yaml-rest-runner/src/main/java/org/elasticsearch/test/rest/yaml/section/Prerequisites.java

@@ -10,8 +10,11 @@ package org.elasticsearch.test.rest.yaml.section;
 
 import org.elasticsearch.test.rest.ESRestTestCase;
 import org.elasticsearch.test.rest.yaml.ClientYamlTestExecutionContext;
+import org.elasticsearch.test.rest.yaml.section.PrerequisiteSection.CapabilitiesCheck;
+import org.elasticsearch.test.rest.yaml.section.PrerequisiteSection.KnownIssue;
 
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.function.Predicate;
 
@@ -45,8 +48,23 @@ public class Prerequisites {
         return context -> clusterFeatures.stream().anyMatch(context::clusterHasFeature);
     }
 
-    static Predicate<ClientYamlTestExecutionContext> skipOnKnownIssue(List<PrerequisiteSection.KnownIssue> knownIssues) {
+    static Predicate<ClientYamlTestExecutionContext> skipOnKnownIssue(List<KnownIssue> knownIssues) {
         return context -> knownIssues.stream()
             .anyMatch(i -> context.clusterHasFeature(i.clusterFeature()) && context.clusterHasFeature(i.fixedBy()) == false);
     }
+
+    static Predicate<ClientYamlTestExecutionContext> requireCapabilities(List<CapabilitiesCheck> checks) {
+        // requirement not fulfilled if unknown / capabilities API not supported
+        return context -> checks.stream().allMatch(check -> checkCapabilities(context, check).orElse(false));
+    }
+
+    static Predicate<ClientYamlTestExecutionContext> skipCapabilities(List<CapabilitiesCheck> checks) {
+        // skip if unknown / capabilities API not supported
+        return context -> checks.stream().anyMatch(check -> checkCapabilities(context, check).orElse(true));
+    }
+
+    private static Optional<Boolean> checkCapabilities(ClientYamlTestExecutionContext context, CapabilitiesCheck check) {
+        Optional<Boolean> b = context.clusterHasCapabilities(check.method(), check.path(), check.parameters(), check.capabilities());
+        return b;
+    }
 }

+ 81 - 2
test/yaml-rest-runner/src/test/java/org/elasticsearch/test/rest/yaml/section/PrerequisiteSectionTests.java

@@ -13,6 +13,7 @@ import org.elasticsearch.common.ParsingException;
 import org.elasticsearch.core.Strings;
 import org.elasticsearch.test.VersionUtils;
 import org.elasticsearch.test.rest.yaml.ClientYamlTestExecutionContext;
+import org.elasticsearch.test.rest.yaml.section.PrerequisiteSection.CapabilitiesCheck;
 import org.elasticsearch.test.rest.yaml.section.PrerequisiteSection.KnownIssue;
 import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.yaml.YamlXContent;
@@ -20,8 +21,11 @@ import org.junit.AssumptionViolatedException;
 
 import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
 import static java.util.Collections.emptyList;
 import static java.util.Collections.singletonList;
 import static org.hamcrest.Matchers.contains;
@@ -36,6 +40,8 @@ import static org.hamcrest.Matchers.not;
 import static org.hamcrest.Matchers.notNullValue;
 import static org.hamcrest.Matchers.nullValue;
 import static org.hamcrest.Matchers.oneOf;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -357,8 +363,8 @@ public class PrerequisiteSectionTests extends AbstractClientYamlTestFragmentPars
             e.getMessage(),
             is(
                 oneOf(
-                    ("Expected fields [cluster_feature, fixed_by], but got [cluster_feature]"),
-                    ("Expected fields [fixed_by, cluster_feature], but got [cluster_feature]")
+                    ("Expected all of [cluster_feature, fixed_by], but got [cluster_feature]"),
+                    ("Expected all of [fixed_by, cluster_feature], but got [cluster_feature]")
                 )
             )
         );
@@ -498,6 +504,42 @@ public class PrerequisiteSectionTests extends AbstractClientYamlTestFragmentPars
         assertThat(parser.nextToken(), nullValue());
     }
 
+    public void testParseRequireAndSkipSectionsCapabilities() throws Exception {
+        parser = createParser(YamlXContent.yamlXContent, """
+            - requires:
+               capabilities:
+                 - path: /a
+                 - method: POST
+                   path: /b
+                   parameters: [param1, param2]
+                 - method: PUT
+                   path: /c
+                   capabilities: [a, b, c]
+               reason: required to run test
+            - skip:
+               capabilities:
+                 - path: /d
+                   parameters: param1
+                   capabilities: a
+               reason: undesired if supported
+            """);
+
+        var skipSectionBuilder = PrerequisiteSection.parseInternal(parser);
+        assertThat(skipSectionBuilder, notNullValue());
+        assertThat(
+            skipSectionBuilder.requiredCapabilities,
+            contains(
+                new CapabilitiesCheck("GET", "/a", null, null),
+                new CapabilitiesCheck("POST", "/b", "param1,param2", null),
+                new CapabilitiesCheck("PUT", "/c", null, "a,b,c")
+            )
+        );
+        assertThat(skipSectionBuilder.skipCapabilities, contains(new CapabilitiesCheck("GET", "/d", "param1", "a")));
+
+        assertThat(parser.currentToken(), equalTo(XContentParser.Token.END_ARRAY));
+        assertThat(parser.nextToken(), nullValue());
+    }
+
     public void testParseRequireAndSkipSectionMultipleClusterFeatures() throws Exception {
         parser = createParser(YamlXContent.yamlXContent, """
             - requires:
@@ -659,6 +701,43 @@ public class PrerequisiteSectionTests extends AbstractClientYamlTestFragmentPars
         assertFalse(section.skipCriteriaMet(mockContext));
     }
 
+    public void testEvaluateCapabilities() {
+        List<CapabilitiesCheck> skipCapabilities = List.of(
+            new CapabilitiesCheck("GET", "/s", null, "c1,c2"),
+            new CapabilitiesCheck("GET", "/s", "p1,p2", "c1")
+        );
+        List<CapabilitiesCheck> requiredCapabilities = List.of(
+            new CapabilitiesCheck("GET", "/r", null, null),
+            new CapabilitiesCheck("GET", "/r", "p1", null)
+        );
+        PrerequisiteSection section = new PrerequisiteSection(
+            List.of(Prerequisites.skipCapabilities(skipCapabilities)),
+            "skip",
+            List.of(Prerequisites.requireCapabilities(requiredCapabilities)),
+            "required",
+            emptyList()
+        );
+
+        var context = mock(ClientYamlTestExecutionContext.class);
+
+        // when the capabilities API is unavailable:
+        assertTrue(section.skipCriteriaMet(context)); // always skip if unavailable
+        assertFalse(section.requiresCriteriaMet(context)); // always fail requirements / skip if unavailable
+
+        when(context.clusterHasCapabilities(anyString(), anyString(), any(), any())).thenReturn(Optional.of(FALSE));
+        assertFalse(section.skipCriteriaMet(context));
+        assertFalse(section.requiresCriteriaMet(context));
+
+        when(context.clusterHasCapabilities("GET", "/s", null, "c1,c2")).thenReturn(Optional.of(TRUE));
+        assertTrue(section.skipCriteriaMet(context));
+
+        when(context.clusterHasCapabilities("GET", "/r", null, null)).thenReturn(Optional.of(TRUE));
+        assertFalse(section.requiresCriteriaMet(context));
+
+        when(context.clusterHasCapabilities("GET", "/r", "p1", null)).thenReturn(Optional.of(TRUE));
+        assertTrue(section.requiresCriteriaMet(context));
+    }
+
     public void evaluateEmpty() {
         var section = new PrerequisiteSection(List.of(), "unsupported", List.of(), "required", List.of());