Add WebTestClient HtmlUnit Support

Fixes gh-4534
This commit is contained in:
Rob Winch 2017-09-08 15:45:17 -05:00
parent 45bac0fd2c
commit a0a0a32bda
8 changed files with 574 additions and 0 deletions

View File

@ -36,6 +36,9 @@ dependencies {
testCompile 'ch.qos.logback:logback-classic'
testCompile 'javax.annotation:jsr250-api:1.0'
testCompile 'ldapsdk:ldapsdk:4.1'
testCompile('net.sourceforge.htmlunit:htmlunit') {
exclude group: 'commons-logging', module: 'commons-logging'
}
testCompile 'org.codehaus.groovy:groovy-all'
testCompile 'org.eclipse.persistence:javax.persistence'
testCompile 'org.hibernate:hibernate-entitymanager'
@ -43,6 +46,13 @@ dependencies {
testCompile ('org.openid4java:openid4java-nodeps') {
exclude group: 'com.google.code.guice', module: 'guice'
}
testCompile('org.seleniumhq.selenium:htmlunit-driver') {
exclude group: 'commons-logging', module: 'commons-logging'
}
testCompile('org.seleniumhq.selenium:selenium-java') {
exclude group: 'commons-logging', module: 'commons-logging'
exclude group: 'io.netty', module: 'netty'
}
testCompile 'org.slf4j:jcl-over-slf4j'
testCompile 'org.springframework.ldap:spring-ldap-core'
testCompile 'org.springframework:spring-expression'

View File

@ -0,0 +1,205 @@
/*
* Copyright 2002-2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.htmlunit.server;
import java.net.URI;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.StringTokenizer;
import com.gargoylesoftware.htmlunit.FormEncodingType;
import com.gargoylesoftware.htmlunit.WebClient;
import com.gargoylesoftware.htmlunit.WebRequest;
import com.gargoylesoftware.htmlunit.util.NameValuePair;
import org.springframework.http.HttpCookie;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
import org.springframework.lang.Nullable;
import org.springframework.test.web.reactive.server.FluxExchangeResult;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import reactor.core.publisher.Mono;
final class HtmlUnitWebTestClient {
private final WebClient webClient;
private final WebTestClient webTestClient;
public HtmlUnitWebTestClient(WebClient webClient, WebTestClient webTestClient) {
Assert.notNull(webClient, "WebClient must not be null");
Assert.notNull(webTestClient, "WebTestClient must not be null");
this.webClient = webClient;
this.webTestClient = webTestClient.mutate()
.filter(new FollowRedirects())
.filter(new CookieManager())
.build();
}
public FluxExchangeResult<String> getResponse(WebRequest webRequest) {
WebTestClient.RequestBodySpec request = this.webTestClient
.method(httpMethod(webRequest))
.uri(uri(webRequest));
contentType(request, webRequest);
cookies(request, webRequest);
headers(request, webRequest);
return content(request, webRequest).exchange().returnResult(String.class);
}
private WebTestClient.RequestHeadersSpec<?> content(WebTestClient.RequestBodySpec request, WebRequest webRequest) {
String requestBody = webRequest.getRequestBody();
if (requestBody == null) {
List<NameValuePair> params = webRequest.getRequestParameters();
if(params != null && !params.isEmpty()) {
return request.body(BodyInserters.fromFormData(formData(params)));
}
return request;
}
return request.body(BodyInserters.fromObject(requestBody));
}
private MultiValueMap<String,String> formData(List<NameValuePair> params) {
MultiValueMap<String,String> result = new LinkedMultiValueMap<>(params.size());
params.forEach( pair -> result.add(pair.getName(), pair.getValue()));
return result;
}
private void contentType(WebTestClient.RequestBodySpec request, WebRequest webRequest) {
String contentType = header("Content-Type", webRequest);
if (contentType == null) {
FormEncodingType encodingType = webRequest.getEncodingType();
if (encodingType != null) {
contentType = encodingType.getName();
}
}
MediaType mediaType = contentType == null ? MediaType.ALL : MediaType.parseMediaType(contentType);
request.contentType(mediaType);
}
private void cookies(WebTestClient.RequestBodySpec request, WebRequest webRequest) {
String cookieHeaderValue = header("Cookie", webRequest);
if (cookieHeaderValue != null) {
StringTokenizer tokens = new StringTokenizer(cookieHeaderValue, "=;");
while (tokens.hasMoreTokens()) {
String cookieName = tokens.nextToken().trim();
Assert.isTrue(tokens.hasMoreTokens(),
() -> "Expected value for cookie name '" + cookieName +
"': full cookie header was [" + cookieHeaderValue + "]");
String cookieValue = tokens.nextToken().trim();
request.cookie(cookieName, cookieValue);
}
}
Set<com.gargoylesoftware.htmlunit.util.Cookie> managedCookies = this.webClient.getCookies(webRequest.getUrl());
for (com.gargoylesoftware.htmlunit.util.Cookie cookie : managedCookies) {
request.cookie(cookie.getName(), cookie.getValue());
}
}
@Nullable
private String header(String headerName, WebRequest webRequest) {
return webRequest.getAdditionalHeaders().get(headerName);
}
private void headers(WebTestClient.RequestBodySpec request, WebRequest webRequest) {
webRequest.getAdditionalHeaders().forEach( (name,value) -> request.header(name, value));
}
private HttpMethod httpMethod(WebRequest webRequest) {
String httpMethod = webRequest.getHttpMethod().name();
return HttpMethod.valueOf(httpMethod);
}
private URI uri(WebRequest webRequest) {
URL url = webRequest.getUrl();
return URI.create(url.toExternalForm());
}
static class FollowRedirects implements ExchangeFilterFunction {
@Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
return next.exchange(request)
.flatMap( response -> redirectIfNecessary(request, next, response));
}
private Mono<ClientResponse> redirectIfNecessary(ClientRequest request, ExchangeFunction next, ClientResponse response) {
URI location = response.headers().asHttpHeaders().getLocation();
if(location != null) {
ClientRequest redirect = ClientRequest.method(HttpMethod.GET, URI.create("http://localhost" + location.toASCIIString()))
.headers(headers -> headers.addAll(request.headers()))
.cookies(cookies -> cookies.addAll(request.cookies()))
.attributes(attributes -> attributes.putAll(request.attributes()))
.build();
return next.exchange(redirect).flatMap( r -> redirectIfNecessary(request, next, r));
}
return Mono.just(response);
}
}
static class CookieManager implements ExchangeFilterFunction {
private Map<String, ResponseCookie> cookies = new HashMap<>();
@Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
return next.exchange(withClientCookies(request))
.doOnSuccess( response -> {
response.cookies().values().forEach( cookies -> {
cookies.forEach( cookie -> {
if(cookie.getMaxAge().isZero()) {
this.cookies.remove(cookie.getName());
} else {
this.cookies.put(cookie.getName(), cookie);
}
});
});
});
}
private ClientRequest withClientCookies(ClientRequest request) {
return ClientRequest.from(request)
.cookies( c -> {
c.addAll(clientCookies());
}).build();
}
private MultiValueMap<String,String> clientCookies() {
MultiValueMap<String,String> result = new LinkedMultiValueMap<>(this.cookies.size());
this.cookies.values().forEach( cookie ->
result.add(cookie.getName(), cookie.getValue())
);
return result;
}
}
}

View File

@ -0,0 +1,77 @@
/*
* Copyright 2002-2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.htmlunit.server;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import com.gargoylesoftware.htmlunit.WebRequest;
import com.gargoylesoftware.htmlunit.WebResponse;
import com.gargoylesoftware.htmlunit.WebResponseData;
import com.gargoylesoftware.htmlunit.util.NameValuePair;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.FluxExchangeResult;
import org.springframework.util.Assert;
/**
* @author Rob Winch
* @since 5.0
*/
final class MockWebResponseBuilder {
private final long startTime;
private final WebRequest webRequest;
private final FluxExchangeResult<String> exchangeResult;
public MockWebResponseBuilder(long startTime, WebRequest webRequest, FluxExchangeResult<String> exchangeResult) {
Assert.notNull(webRequest, "WebRequest must not be null");
Assert.notNull(exchangeResult, "FluxExchangeResult must not be null");
this.startTime = startTime;
this.webRequest = webRequest;
this.exchangeResult = exchangeResult;
}
public WebResponse build() throws IOException {
WebResponseData webResponseData = webResponseData();
long endTime = System.currentTimeMillis();
return new WebResponse(webResponseData, this.webRequest, endTime - this.startTime);
}
private WebResponseData webResponseData() throws IOException {
List<NameValuePair> responseHeaders = responseHeaders();
HttpStatus status = this.exchangeResult.getStatus();
return new WebResponseData(this.exchangeResult.getResponseBodyContent(), status.value(), status.getReasonPhrase(), responseHeaders);
}
private List<NameValuePair> responseHeaders() {
HttpHeaders responseHeaders = this.exchangeResult.getResponseHeaders();
List<NameValuePair> result = new ArrayList<>(responseHeaders.size());
responseHeaders.forEach( (headerName, headerValues) ->
headerValues.forEach( headerValue ->
result.add(new NameValuePair(headerName, headerValue))
)
);
return result;
}
}

View File

@ -0,0 +1,48 @@
/*
*
* * Copyright 2002-2017 the original author or authors.
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/
package org.springframework.security.htmlunit.server;
import com.gargoylesoftware.htmlunit.WebClient;
import org.openqa.selenium.WebDriver;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.web.servlet.htmlunit.webdriver.WebConnectionHtmlUnitDriver;
/**
* @author Rob Winch
* @since 5.0
*/
public class WebTestClientHtmlUnitDriverBuilder {
private final WebTestClient webTestClient;
private WebTestClientHtmlUnitDriverBuilder(WebTestClient webTestClient) {
this.webTestClient = webTestClient;
}
public WebDriver build() {
WebConnectionHtmlUnitDriver driver = new WebConnectionHtmlUnitDriver();
WebClient webClient = driver.getWebClient();
WebTestClientWebConnection connection = new WebTestClientWebConnection(this.webTestClient, webClient);
driver.setWebConnection(connection);
return driver;
}
public static WebTestClientHtmlUnitDriverBuilder webTestClientSetup(WebTestClient webTestClient) {
return new WebTestClientHtmlUnitDriverBuilder(webTestClient);
}
}

View File

@ -0,0 +1,131 @@
/*
*
* * Copyright 2002-2017 the original author or authors.
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/
package org.springframework.security.htmlunit.server;
import org.junit.Test;
import org.openqa.selenium.WebDriver;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.web.util.TextEscapeUtils;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Rob Winch
* @since 5.0
*/
public class WebTestClientHtmlUnitDriverBuilderTests {
@Test
public void helloWorld() {
WebTestClient webTestClient = WebTestClient
.bindToController(new HelloWorldController())
.build();
WebDriver driver = WebTestClientHtmlUnitDriverBuilder
.webTestClientSetup(webTestClient).build();
driver.get("http://localhost/");
assertThat(driver.getPageSource()).contains("Hello World");
}
/**
* @author Rob Winch
* @since 5.0
*/
@Controller
class HelloWorldController {
@ResponseBody
@GetMapping(produces = MediaType.TEXT_HTML_VALUE)
public String index() {
return "<html>\n"
+ "<head>\n"
+ "<title>Hello World</title>\n"
+ "</head>\n"
+ "<body>\n"
+ "<h1>Hello World</h1>\n"
+ "</body>\n"
+ "</html>";
}
}
@Test
public void cookies() {
WebTestClient webTestClient = WebTestClient
.bindToController(new CookieController())
.build();
WebDriver driver = WebTestClientHtmlUnitDriverBuilder
.webTestClientSetup(webTestClient).build();
driver.get("http://localhost/cookie");
assertThat(driver.getPageSource()).contains("theCookie");
driver.get("http://localhost/cookie/delete");
assertThat(driver.getPageSource()).contains("null");
}
@Controller
@ResponseBody
class CookieController {
@GetMapping(path = "/", produces = MediaType.TEXT_HTML_VALUE)
public String view(@CookieValue(required = false) String cookieName) {
return "<html>\n"
+ "<head>\n"
+ "<title>Hello World</title>\n"
+ "</head>\n"
+ "<body>\n"
+ "<h1>" + TextEscapeUtils.escapeEntities(cookieName) + "</h1>\n"
+ "</body>\n"
+ "</html>";
}
@GetMapping("/cookie")
public Mono<Void> setCookie(ServerHttpResponse response) {
response.addCookie(ResponseCookie.from("cookieName", "theCookie").build());
return redirect(response);
}
private Mono<Void> redirect(ServerHttpResponse response) {
response.setStatusCode(HttpStatus.MOVED_PERMANENTLY);
response.getHeaders().setLocation(URI.create("/"));
return response.setComplete();
}
@GetMapping("/cookie/delete")
public Mono<Void> deleteCookie(ServerHttpResponse response) {
response.addCookie(
ResponseCookie.from("cookieName", "").maxAge(Duration.ofSeconds(0)).build());
return redirect(response);
}
}
}

View File

@ -0,0 +1,95 @@
/*
*
* * Copyright 2002-2017 the original author or authors.
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/
package org.springframework.security.htmlunit.server;
import java.io.IOException;
import com.gargoylesoftware.htmlunit.WebClient;
import com.gargoylesoftware.htmlunit.WebConnection;
import com.gargoylesoftware.htmlunit.WebRequest;
import com.gargoylesoftware.htmlunit.WebResponse;
import org.springframework.lang.Nullable;
import org.springframework.test.web.reactive.server.FluxExchangeResult;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.Assert;
/**
* @author Rob Winch
* @since 5.0
*/
public class WebTestClientWebConnection implements WebConnection {
private final WebTestClient webTestClient;
private final String contextPath;
private final HtmlUnitWebTestClient requestBuilder;
private WebClient webClient;
public WebTestClientWebConnection(WebTestClient webTestClient, WebClient webClient) {
this(webTestClient, webClient, "");
}
public WebTestClientWebConnection(WebTestClient webTestClient, WebClient webClient, String contextPath) {
Assert.notNull(webTestClient, "MockMvc must not be null");
Assert.notNull(webClient, "WebClient must not be null");
validateContextPath(contextPath);
this.webClient = webClient;
this.webTestClient = webTestClient;
this.contextPath = contextPath;
this.requestBuilder = new HtmlUnitWebTestClient(this.webClient, this.webTestClient);
}
/**
* Validate the supplied {@code contextPath}.
* <p>If the value is not {@code null}, it must conform to
* {@link javax.servlet.http.HttpServletRequest#getContextPath()} which
* states that it can be an empty string and otherwise must start with
* a "/" character and not end with a "/" character.
* @param contextPath the path to validate
*/
static void validateContextPath(@Nullable String contextPath) {
if (contextPath == null || "".equals(contextPath)) {
return;
}
Assert.isTrue(contextPath.startsWith("/"), () -> "contextPath '" + contextPath + "' must start with '/'.");
Assert.isTrue(!contextPath.endsWith("/"), () -> "contextPath '" + contextPath + "' must not end with '/'.");
}
public void setWebClient(WebClient webClient) {
Assert.notNull(webClient, "WebClient must not be null");
this.webClient = webClient;
}
@Override
public WebResponse getResponse(WebRequest webRequest) throws IOException {
long startTime = System.currentTimeMillis();
FluxExchangeResult<String> exchangeResult = this.requestBuilder.getResponse(webRequest);
return new MockWebResponseBuilder(startTime, webRequest, exchangeResult).build();
}
@Override
public void close() {}
}

View File

@ -20,6 +20,7 @@ dependencyManagement {
dependency 'org.powermock:powermock-module-junit4:1.6.2'
dependency 'org.powermock:powermock-reflect:1.6.2'
dependency 'org.python:jython:2.5.0'
dependency 'org.seleniumhq.selenium:selenium-java:3.4.0'
dependency 'org.spockframework:spock-core:1.0-groovy-2.4'
dependency 'org.spockframework:spock-spring:1.0-groovy-2.4'
}

View File

@ -18,12 +18,15 @@
package org.springframework.security.test.web.reactive.server;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.WebFilterChainFilter;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.web.reactive.server.WebTestClient.Builder;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.WebFilter;
import reactor.core.publisher.Flux;
/**
* Provides a convenient mechanism for running {@link WebTestClient} against
@ -39,6 +42,10 @@ public class WebTestClientBuilder {
return WebTestClient.bindToController(new Http200RestController()).webFilter(webFilters).configureClient();
}
public static Builder bindToWebFilters(SecurityWebFilterChain securityWebFilterChain) {
return bindToWebFilters(WebFilterChainFilter.fromSecurityWebFilterChains(securityWebFilterChain));
}
@RestController
static class Http200RestController {
@RequestMapping("/**")