Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<DynamicTest> 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("<a>x<b/>y</a>", "<a><b/>z</a>")) {
HttpResponse<Void> 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("<a>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());
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -387,28 +387,52 @@ private static List<String> 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<String> 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<String, String> prefixToUri;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", "<a>x<b/>y</a>", 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", "<a><b/>z</a>", Map.of(), Map.of()), body(equalTo("undefined")).xPath("//a").build()));
assertTrue(check(req("POST", "/x", "<a/>", Map.of(), Map.of()), body(equalTo("undefined")).xPath("//a").build()));
assertTrue(check(req("POST", "/x", "<a>x&amp;y<![CDATA[z]]></a>", 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", "<a id=\"7\">x<b/>y</a>", 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 = "<?xml version=\"1.0\"?><!DOCTYPE t [<!ENTITY x SYSTEM \"file:///etc/passwd\">]><t>&x;</t>";
Expand Down
Loading