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;";