From b80869983701c5cd4dc4045a83b26690d56c1d3d Mon Sep 17 00:00:00 2001 From: Mohsen Zainalpour Date: Fri, 16 Oct 2026 23:42:08 -0400 Subject: [PATCH] fix(verify): read an XPath-selected element as the engine does from rift 0.23.0 rift 0.23.0 reads a selected element as Mountebank's nodeValue, its first child node's text, or "undefined" when it has none or starts with an element (rift#1495). The client-side evaluator behind recorded(RequestMatch) still read the element's whole text, so on 0.23 it could disagree with what the engine matched and verify counted. It now reads the first child, parses text, references and CDATA as one node, and evaluates a string-valued selector such as string(//a), the engine's way to read the whole text, instead of refusing it. Closes #330 --- .../conformance/XPathRecordedMatchIT.java | 54 +++++++++++++++++++ .../rift/verify/PredicateEvaluator.java | 38 ++++++++++--- .../rift/verify/PredicateEvaluatorTest.java | 21 ++++++++ 3 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/XPathRecordedMatchIT.java diff --git a/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/XPathRecordedMatchIT.java b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/XPathRecordedMatchIT.java new file mode 100644 index 0000000..fc27dbc --- /dev/null +++ b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/XPathRecordedMatchIT.java @@ -0,0 +1,54 @@ +package io.github.achirdlabs.rift.conformance; + +import io.github.achirdlabs.rift.Imposter; +import io.github.achirdlabs.rift.Rift; +import io.github.achirdlabs.rift.verify.RequestMatch; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.stream.Stream; + +import static io.github.achirdlabs.rift.conformance.LiveEngine.engine; +import static io.github.achirdlabs.rift.conformance.LiveEngine.gated; +import static io.github.achirdlabs.rift.dsl.RiftDsl.body; +import static io.github.achirdlabs.rift.dsl.RiftDsl.equalTo; +import static io.github.achirdlabs.rift.dsl.RiftDsl.imposter; +import static io.github.achirdlabs.rift.dsl.RiftDsl.ok; +import static io.github.achirdlabs.rift.dsl.RiftDsl.onRequest; +import static io.github.achirdlabs.rift.dsl.RiftDsl.status; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@code recorded(match)} filters an XPath predicate the way the engine matched it (#330): from rift + * 0.23.0 a selected element reads as its first child node (rift#1495). Needs {@code RIFT_IT=1}. + */ +class XPathRecordedMatchIT { + + private static final HttpClient CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + + @TestFactory + Stream theClientSideFilterAgreesWithTheEngine() { + return gated("an element selection reads as the engine reads it", () -> { + try (Rift rift = engine()) { + Imposter imposter = rift.create(imposter("xpath").record() + .stub(onRequest().withPredicate(body(equalTo("x")).xPath("//a")).willReturn(ok())) + .stub(onRequest().willReturn(status(404)))); + for (String xml : List.of("xy", "z")) { + HttpResponse answer = CLIENT.send(HttpRequest.newBuilder(URI.create(imposter.uri() + "/")) + .timeout(Duration.ofSeconds(5)).POST(HttpRequest.BodyPublishers.ofString(xml)).build(), + HttpResponse.BodyHandlers.discarding()); + int matchedByEngine = answer.statusCode() == 200 ? 1 : 0; + assertEquals(xml.startsWith("x") ? 1 : 0, matchedByEngine, "the engine's answer for " + xml); + } + assertEquals(1, imposter.recorded(RequestMatch.of(List.of(body(equalTo("x")).xPath("//a").build()))).size()); + assertEquals(1, imposter.recorded(RequestMatch.of(List.of(body(equalTo("undefined")).xPath("//a").build()))).size()); + } + }); + } +} diff --git a/rift-java-core/src/main/java/io/github/achirdlabs/rift/verify/PredicateEvaluator.java b/rift-java-core/src/main/java/io/github/achirdlabs/rift/verify/PredicateEvaluator.java index 88335d7..69e2234 100644 --- a/rift-java-core/src/main/java/io/github/achirdlabs/rift/verify/PredicateEvaluator.java +++ b/rift-java-core/src/main/java/io/github/achirdlabs/rift/verify/PredicateEvaluator.java @@ -387,28 +387,52 @@ private static List extractXPath(RecordedRequest request, PredicateSelec dbf.setFeature(javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING, true); dbf.setXIncludeAware(false); dbf.setExpandEntityReferences(false); + // text, references and CDATA as one text node, as the engine's XML parser sees them + dbf.setCoalescing(true); DocumentBuilder builder = dbf.newDocumentBuilder(); doc = builder.parse(new InputSource(new StringReader(body))); } catch (javax.xml.parsers.ParserConfigurationException | org.xml.sax.SAXException | java.io.IOException e) { // a non-XML (or entity-laden) body simply doesn't match this xpath predicate return List.of(); } + XPath xpath = XPathFactory.newInstance().newXPath(); + xPath.namespaces().ifPresent(ns -> xpath.setNamespaceContext(new MapNamespaceContext(ns))); try { - XPath xpath = XPathFactory.newInstance().newXPath(); - xPath.namespaces().ifPresent(ns -> xpath.setNamespaceContext(new MapNamespaceContext(ns))); NodeList nodes = (NodeList) xpath.evaluate(xPath.selector(), doc, XPathConstants.NODESET); List out = new ArrayList<>(); for (int i = 0; i < nodes.getLength(); i++) { - out.add(nodes.item(i).getTextContent()); + out.add(nodeValue(nodes.item(i))); } return out; - } catch (javax.xml.xpath.XPathExpressionException e) { - // a malformed xpath SELECTOR is a bad predicate (like a bad regex), not a non-matching request - throw new io.github.achirdlabs.rift.error.InvalidDefinition( - "invalid xpath selector \"" + xPath.selector() + "\": " + e.getMessage()); + } catch (javax.xml.xpath.XPathExpressionException notANodeSet) { + try { + // a selector yielding a string, number or boolean (string(//a), count(//a)) is one value + return List.of(xpath.evaluate(xPath.selector(), doc)); + } catch (javax.xml.xpath.XPathExpressionException e) { + // a malformed xpath SELECTOR is a bad predicate (like a bad regex), not a non-matching request + throw new io.github.achirdlabs.rift.error.InvalidDefinition( + "invalid xpath selector \"" + xPath.selector() + "\": " + notANodeSet.getMessage()); + } } } + /** + * A selected node's value as the engine reads it from rift 0.23.0 (rift#1495), after Mountebank's + * {@code nodeValue}: an element reads as its first child node's text, or {@code "undefined"} when + * it has none or starts with an element; any other node reads as its own value. + */ + private static String nodeValue(org.w3c.dom.Node node) { + if (node.getNodeType() != org.w3c.dom.Node.ELEMENT_NODE) { + return node.getNodeValue() == null ? node.getTextContent() : node.getNodeValue(); + } + org.w3c.dom.Node first = node.getFirstChild(); + if (first == null || first.getNodeType() == org.w3c.dom.Node.ELEMENT_NODE + || first.getNodeValue() == null || first.getNodeValue().isEmpty()) { + return "undefined"; + } + return first.getNodeValue(); + } + private static final class MapNamespaceContext implements NamespaceContext { private final Map prefixToUri; diff --git a/rift-java-core/src/test/java/io/github/achirdlabs/rift/verify/PredicateEvaluatorTest.java b/rift-java-core/src/test/java/io/github/achirdlabs/rift/verify/PredicateEvaluatorTest.java index b9239be..1a3c603 100644 --- a/rift-java-core/src/test/java/io/github/achirdlabs/rift/verify/PredicateEvaluatorTest.java +++ b/rift-java-core/src/test/java/io/github/achirdlabs/rift/verify/PredicateEvaluatorTest.java @@ -187,6 +187,27 @@ void malformedXPathSelectorSurfacesAsInvalidDefinition() { assertThrows(InvalidDefinition.class, () -> check(r, body(equalTo("1")).xPath("///[bad(").build())); } + @Test + void anXPathElementReadsAsItsFirstChildNodeAsTheEngineDoes() { + // rift 0.23.0 (rift#1495) reads a selected element as Mountebank's nodeValue: its first child's + // text, "undefined" when it has none or starts with an element + RecordedRequest mixed = req("POST", "/x", "xy", Map.of(), Map.of()); + assertTrue(check(mixed, body(equalTo("x")).xPath("//a").build())); + assertFalse(check(mixed, body(equalTo("xy")).xPath("//a").build())); + assertTrue(check(req("POST", "/x", "z", Map.of(), Map.of()), body(equalTo("undefined")).xPath("//a").build())); + assertTrue(check(req("POST", "/x", "", Map.of(), Map.of()), body(equalTo("undefined")).xPath("//a").build())); + assertTrue(check(req("POST", "/x", "x&y", Map.of(), Map.of()), + body(equalTo("x&yz")).xPath("//a").build()), "text, references and CDATA are one node"); + } + + @Test + void textAndAttributeSelectionsAndStringFunctionsReadTheirValue() { + RecordedRequest r = req("POST", "/x", "xy", Map.of(), Map.of()); + assertTrue(check(r, body(equalTo("x")).xPath("//a/text()").build())); + assertTrue(check(r, body(equalTo("7")).xPath("//a/@id").build())); + assertTrue(check(r, body(equalTo("xy")).xPath("string(//a)").build()), "string() reads the whole text"); + } + @Test void xxeEntityInBodyIsNotResolved() { String xxe = "]>&x;";