test.java.com.cloudant.tests.HttpTest Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of cloudant-client Show documentation
Show all versions of cloudant-client Show documentation
Official Cloudant client for Java
/*
* Copyright © 2017, 2019 IBM Corp. All rights reserved.
*
* 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 com.cloudant.tests;
import static com.cloudant.tests.util.MockWebServerResources.EXPECTED_OK_COOKIE;
import static com.cloudant.tests.util.MockWebServerResources.EXPECTED_OK_COOKIE_2;
import static com.cloudant.tests.util.MockWebServerResources.OK_COOKIE;
import static com.cloudant.tests.util.MockWebServerResources.OK_COOKIE_2;
import static org.hamcrest.CoreMatchers.anyOf;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.core.Is.is;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import com.cloudant.client.api.ClientBuilder;
import com.cloudant.client.api.CloudantClient;
import com.cloudant.client.org.lightcouch.CouchDbException;
import com.cloudant.client.org.lightcouch.TooManyRequestsException;
import com.cloudant.http.Http;
import com.cloudant.http.HttpConnection;
import com.cloudant.http.HttpConnectionInterceptorContext;
import com.cloudant.http.HttpConnectionRequestInterceptor;
import com.cloudant.http.HttpConnectionResponseInterceptor;
import com.cloudant.http.interceptors.BasicAuthInterceptor;
import com.cloudant.http.interceptors.Replay429Interceptor;
import com.cloudant.http.internal.interceptors.CookieInterceptor;
import com.cloudant.test.main.RequiresCloudant;
import com.cloudant.tests.extensions.CloudantClientExtension;
import com.cloudant.tests.extensions.DatabaseExtension;
import com.cloudant.tests.extensions.DisabledWithIam;
import com.cloudant.tests.extensions.MockWebServerExtension;
import com.cloudant.tests.extensions.MultiExtension;
import com.cloudant.tests.util.HttpFactoryParameterizedTest;
import com.cloudant.tests.util.MockWebServerResources;
import com.cloudant.tests.util.TestTimer;
import com.cloudant.tests.util.Utils;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import org.apache.commons.io.IOUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.Extension;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.api.extension.TestTemplateInvocationContext;
import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider;
import org.junit.jupiter.api.function.Executable;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.net.URLClassLoader;
import java.net.URLDecoder;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import java.util.stream.Stream;
@ExtendWith(HttpTest.ParameterProvider.class)
public class HttpTest extends HttpFactoryParameterizedTest {
static class ParameterProvider implements TestTemplateInvocationContextProvider {
@Override
public boolean supportsTestTemplate(ExtensionContext context) {
return true;
}
@Override
public Stream provideTestTemplateInvocationContexts
(ExtensionContext context) {
return Stream.of(invocationContext(false),
invocationContext(true));
}
public static TestTemplateInvocationContext invocationContext(final boolean okUsable) {
return new TestTemplateInvocationContext() {
@Override
public String getDisplayName(int invocationIndex) {
return String.format("okUsable:%s", okUsable);
}
@Override
public List getAdditionalExtensions() {
return Collections.singletonList(new ParameterResolver() {
@Override
public boolean supportsParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
switch (parameterContext.getIndex()) {
case 0:
return parameterContext.getParameter().getType().equals
(boolean.class);
}
return false;
}
@Override
public Object resolveParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
switch (parameterContext.getIndex()) {
case 0:
return okUsable;
}
return null;
}
});
}
};
}
}
private String data = "{\"hello\":\"world\"}";
public static CloudantClientExtension clientResource = new CloudantClientExtension();
public static DatabaseExtension.PerClass dbResource = new DatabaseExtension.PerClass
(clientResource);
public static MockWebServerExtension mockWebServerExt = new MockWebServerExtension();
@RegisterExtension
public static MultiExtension extensions = new MultiExtension(clientResource, dbResource,
mockWebServerExt);
public MockWebServer mockWebServer;
@BeforeEach
public void beforeEach() {
mockWebServer = mockWebServerExt.get();
}
/*
* Basic test that we can write a document body by POSTing to a known database
*/
@TestTemplate
@DisabledWithIam
public void testWriteToServerOk() throws Exception {
HttpConnection conn = new HttpConnection("POST", new URL(dbResource.getDbURIWithUserInfo()),
"application/json");
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes());
// nothing read from stream
assertEquals(data.getBytes().length, bis.available());
conn.setRequestBody(bis);
HttpConnection response = conn.execute();
// Consume response stream
String responseStr = response.responseAsString();
String okPattern = ".*\"ok\"\\s*:\\s*true.*";
assertTrue(Pattern.compile(okPattern,
Pattern.DOTALL).matcher(responseStr).matches(), "There should be an ok response: " +
"" + responseStr);
// stream was read to end
assertEquals(0, bis.available());
assertEquals(2, response.getConnection().getResponseCode
() / 100, "Should be a 2XX response code");
}
/*
* Basic test to check that an IOException is thrown when we attempt to get the response
* without first calling execute()
*/
@TestTemplate
public void testReadBeforeExecute() throws Exception {
HttpConnection conn = new HttpConnection("POST", new URL(dbResource.getDbURIWithUserInfo()),
"application/json");
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes());
// nothing read from stream
assertEquals(data.getBytes().length, bis.available());
conn.setRequestBody(bis);
try {
String response = conn.responseAsString();
fail("IOException not thrown as expected instead had response " + response);
} catch (IOException ioe) {
; // "Attempted to read response from server before calling execute()"
}
// stream was not read because execute() was not called
assertEquals(data.getBytes().length, bis.available());
}
//NOTE: This test doesn't work with specified couch servers,
// the URL will always include the creds specified for the test
//
// A couchdb server needs to be set and running with the correct
// security settings, the database *must* not be public, it *must*
// be named cookie_test
//
@TestTemplate
@DisabledWithIam
@RequiresCloudant
public void testCookieAuthWithoutRetry() throws IOException {
CookieInterceptor interceptor = new CookieInterceptor(CloudantClientHelper.SERVER_USER,
CloudantClientHelper.SERVER_PASSWORD, clientResource.get().getBaseUri().toString());
HttpConnection conn = new HttpConnection("POST", dbResource.get().getDBUri().toURL(),
"application/json");
conn.responseInterceptors.add(interceptor);
conn.requestInterceptors.add(interceptor);
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes());
// nothing read from stream
assertEquals(data.getBytes().length, bis.available());
conn.setRequestBody(bis);
HttpConnection responseConn = conn.execute();
// stream was read to end
assertEquals(0, bis.available());
assertEquals(2, responseConn.getConnection().getResponseCode() / 100);
//check the json
Gson gson = new Gson();
InputStream is = responseConn.responseAsInputStream();
try {
JsonObject response = gson.fromJson(new InputStreamReader(is), JsonObject.class);
assertTrue(response.has("ok"));
assertTrue(response.get("ok").getAsBoolean());
assertTrue(response.has("id"));
assertTrue(response.has("rev"));
} finally {
is.close();
}
}
@TestTemplate
public void testCookieAuthWithPath() throws Exception {
MockWebServer mockWebServer = new MockWebServer();
mockWebServer.enqueue(OK_COOKIE);
mockWebServer.enqueue(MockWebServerResources.JSON_OK);
CloudantClient client = ClientBuilder.url(mockWebServer.url("/pathex").url())
.username("user")
.password("password")
.build();
//send single request
client.database("test", true);
assertThat("_session is the second element of the path",
mockWebServer.takeRequest().getPath(),
is("/pathex/_session"));
}
/**
* Test that adding the Basic Authentication interceptor to HttpConnection
* will complete with a response code of 200. The response input stream
* is expected to hold the newly created document's id and rev.
*/
@TestTemplate
@DisabledWithIam
@RequiresCloudant
public void testBasicAuth() throws IOException {
BasicAuthInterceptor interceptor =
new BasicAuthInterceptor(CloudantClientHelper.SERVER_USER
+ ":" + CloudantClientHelper.SERVER_PASSWORD);
HttpConnection conn = new HttpConnection("POST", dbResource.get().getDBUri().toURL(),
"application/json");
conn.requestInterceptors.add(interceptor);
ByteArrayInputStream bis = new ByteArrayInputStream(data.getBytes());
// nothing read from stream
assertEquals(data.getBytes().length, bis.available());
conn.setRequestBody(bis);
HttpConnection responseConn = conn.execute();
// stream was read to end
assertEquals(0, bis.available());
assertEquals(2, responseConn.getConnection().getResponseCode() / 100);
//check the json
Gson gson = new Gson();
InputStream is = responseConn.responseAsInputStream();
try {
JsonObject response = gson.fromJson(new InputStreamReader(is), JsonObject.class);
assertTrue(response.has("ok"));
assertTrue(response.get("ok").getAsBoolean());
assertTrue(response.has("id"));
assertTrue(response.has("rev"));
} finally {
is.close();
}
}
/**
* This test mocks up a server to receive the _session request and asserts that the request
* body is correctly encoded (per application/x-www-form-urlencoded). Because it requires a
* body this test also relies on Expect: 100-continue working in the client as that is enabled
* by default.
*
* @throws Exception
*/
@TestTemplate
public void cookieInterceptorURLEncoding() throws Exception {
final String mockUser = "myStrangeUsername=&?";
String mockPass = "?&=NotAsStrangeInAPassword";
//expect a cookie request then a GET
mockWebServer.enqueue(OK_COOKIE);
mockWebServer.enqueue(new MockResponse());
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.username(mockUser)
.password(mockPass)
.build();
//the GET request will try to get a session, then perform the GET
String response = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
assertTrue(response.isEmpty(), "There should be no response body on the mock response");
RecordedRequest r = MockWebServerResources.takeRequestWithTimeout(mockWebServer);
String sessionRequestContent = r.getBody().readString(Charset.forName("UTF-8"));
assertNotNull(sessionRequestContent, "The _session request should have non-null content");
//expecting name=...&password=...
String[] parts = Utils.splitAndAssert(sessionRequestContent, "&", 1);
String username = URLDecoder.decode(Utils.splitAndAssert(parts[0], "=", 1)[1], "UTF-8");
assertEquals(mockUser, username, "The username URL decoded username should match");
String password = URLDecoder.decode(Utils.splitAndAssert(parts[1], "=", 1)[1], "UTF-8");
assertEquals(mockPass, password, "The username URL decoded password should match");
}
/**
* This test check that the cookie is renewed if the server presents a Set-Cookie header
* after the cookie authentication.
*
* @throws Exception
*/
@TestTemplate
public void cookieRenewal() throws Exception {
final String hello = "{\"hello\":\"world\"}\r\n";
final String renewalCookieToken =
"RenewCookie_a2ltc3RlYmVsOjUxMzRBQTUzOtiY2_IDUIdsTJEVNEjObAbyhrgz";
// Request sequence
// _session request to get Cookie
// GET request -> 200 with a Set-Cookie
// GET replay -> 200
mockWebServer.enqueue(OK_COOKIE);
mockWebServer.enqueue(new MockResponse().setResponseCode(200).addHeader("Set-Cookie",
MockWebServerResources.authSessionCookie(renewalCookieToken, null))
.setBody(hello));
mockWebServer.enqueue(new MockResponse());
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.username("a")
.password("b")
.build();
String response = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
assertEquals(hello, response, "The expected response should be received");
// assert that there were 2 calls
assertEquals(2, mockWebServer
.getRequestCount(), "The server should have received 2 requests");
assertEquals("/_session", MockWebServerResources.takeRequestWithTimeout(mockWebServer)
.getPath(), "The request should have been for /_session");
assertEquals("/", MockWebServerResources.takeRequestWithTimeout(mockWebServer).getPath(),
"The request should have been for /");
String secondResponse = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
assertTrue(
secondResponse.isEmpty(), "There should be no response body on the mock response"
+ secondResponse);
// also assert that there were 3 calls
assertEquals(3, mockWebServer
.getRequestCount(), "The server should have received 3 requests");
// this is the request that should have the new cookie.
RecordedRequest request = MockWebServerResources.takeRequestWithTimeout(mockWebServer);
assertEquals("/", request.getPath(), "The request should have been for path /");
String headerValue = request.getHeader("Cookie");
// The cookie may or may not have the session id quoted, so check both
assertThat("The Cookie header should contain the expected session value", headerValue,
anyOf(containsString(MockWebServerResources.authSession(renewalCookieToken)),
containsString(MockWebServerResources.authSessionUnquoted
(renewalCookieToken))));
}
/**
* This test checks that the cookie is successfully renewed if a 403 with an error of
* "credentials_expired" is returned.
*
* @throws Exception
*/
@TestTemplate
public void cookie403Renewal() throws Exception {
// Test for a 403 with expired credentials, should result in 4 requests
basic403Test("credentials_expired", "Session expired", 4);
}
/**
* This test checks that if we get a 403 that is not an error of "credentials_expired" then
* the exception is correctly thrown and the error stream is deserialized. This is important
* because the CookieInterceptor will have consumed 403 error streams to check for the expiry.
*
* @throws Exception
*/
@TestTemplate
public void handleNonExpiry403() throws Exception {
// Test for a non-expiry 403, expect 2 requests
basic403Test("403_not_expired_test", "example reason", 2);
}
/**
* Same as {@link #handleNonExpiry403()} but with no reason property in the JSON.
*
* @throws Exception
*/
@TestTemplate
public void handleNonExpiry403NoReason() throws Exception {
// Test for a non-expiry 403, expect 2 requests
basic403Test("403_not_expired_test", null, 2);
}
/**
* * Same as {@link #handleNonExpiry403()} but with a {@code null} reason property in the JSON.
*
* @throws Exception
*/
@TestTemplate
public void handleNonExpiry403NullReason() throws Exception {
// Test for a non-expiry 403, expect 2 requests
basic403Test("403_not_expired_test", "null", 2);
}
/**
* Method that performs a basic test for a 403 response. The sequence of requests is:
*
* - _session request to get Cookie
* - GET request -> a 403 response
* - _session for new cookie*
* - GET replay -> a 200 response*
*
* The requests annotated * should only happen in the credentials_expired 403 case
*
* @param error the response JSON error content for the 403
* @param reason the response JSON reason content for the 403
*/
private void basic403Test(String error, String reason, int expectedRequests) throws
Exception {
mockWebServer.enqueue(OK_COOKIE);
JsonObject responseBody = new JsonObject();
responseBody.add("error", new JsonPrimitive(error));
JsonElement jsonReason;
if (reason != null) {
if ("null".equals(reason)) {
jsonReason = JsonNull.INSTANCE;
reason = null; // For the assertion we need a real null, not a JsonNull
} else {
jsonReason = new JsonPrimitive(reason);
}
responseBody.add("reason", jsonReason);
}
mockWebServer.enqueue(new MockResponse().setResponseCode(403).setBody(responseBody
.toString()));
mockWebServer.enqueue(OK_COOKIE);
mockWebServer.enqueue(new MockResponse());
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.username("a")
.password("b")
.build();
//the GET request will try to get a session, then perform the GET
//the GET will result in a 403, which in a renewal case should mean another request to
// _session followed by a replay of GET
try {
String response = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
assertTrue(response.isEmpty(), "There should be no response body on the mock response");
if (!error.equals("credentials_expired")) {
fail("A 403 not due to cookie expiry should result in a CouchDbException");
}
} catch (CouchDbException e) {
assertEquals(error, e.getError(), "The exception error should be the expected message");
assertEquals(reason, e
.getReason(), "The exception reason should be the expected message");
}
// also assert that there were the correct number of calls
assertEquals(
expectedRequests, mockWebServer
.getRequestCount(), "The server should receive the expected number of " +
"requests");
}
@TestTemplate
public void inputStreamRetryString() throws Exception {
HttpConnection request = Http.POST(mockWebServer.url("/").url(), "application/json");
String content = "abcde";
request.setRequestBody(content);
testInputStreamRetry(request, content.getBytes("UTF-8"));
}
@TestTemplate
public void inputStreamRetryBytes() throws Exception {
HttpConnection request = Http.POST(mockWebServer.url("/").url(), "application/json");
byte[] content = "abcde".getBytes("UTF-8");
request.setRequestBody(content);
testInputStreamRetry(request, content);
}
private static class UnmarkableInputStream extends InputStream {
private final byte[] content;
int read;
int available;
UnmarkableInputStream(byte[] content) {
this.content = content;
available = content.length;
read = 0;
}
@Override
public int read() throws IOException {
if (available == 0) {
return -1;
} else {
int i = content[read];
read++;
available--;
return i;
}
}
@Override
public boolean markSupported() {
return false;
}
}
@TestTemplate
public void inputStreamRetry() throws Exception {
HttpConnection request = Http.POST(mockWebServer.url("/").url(), "application/json");
final byte[] content = "abcde".getBytes("UTF-8");
// Mock up an input stream that doesn't support marking
request.setRequestBody(new UnmarkableInputStream(content));
testInputStreamRetry(request, content);
}
@TestTemplate
public void inputStreamRetryWithLength() throws Exception {
HttpConnection request = Http.POST(mockWebServer.url("/").url(), "application/json");
final byte[] content = "abcde".getBytes("UTF-8");
// Mock up an input stream that doesn't support marking
request.setRequestBody(new UnmarkableInputStream(content), content.length);
testInputStreamRetry(request, content);
}
private static class TestInputStreamGenerator implements HttpConnection.InputStreamGenerator {
private final byte[] content;
TestInputStreamGenerator(byte[] content) {
this.content = content;
}
@Override
public InputStream getInputStream() throws IOException {
return new ByteArrayInputStream(content);
}
}
@TestTemplate
public void inputStreamRetryGenerator() throws Exception {
HttpConnection request = Http.POST(mockWebServer.url("/").url(), "application/json");
byte[] content = "abcde".getBytes("UTF-8");
request.setRequestBody(new TestInputStreamGenerator(content));
testInputStreamRetry(request, content);
}
@TestTemplate
public void inputStreamRetryGeneratorWithLength() throws Exception {
HttpConnection request = Http.POST(mockWebServer.url("/").url(), "application/json");
byte[] content = "abcde".getBytes("UTF-8");
request.setRequestBody(new TestInputStreamGenerator(content), content.length);
testInputStreamRetry(request, content);
}
private void testInputStreamRetry(HttpConnection request, byte[] expectedContent) throws
Exception {
final MockResponse retry = new MockResponse().setResponseCode(444);
mockWebServer.enqueue(retry);
mockWebServer.enqueue(new MockResponse());
HttpConnection response = CloudantClientHelper.newMockWebServerClientBuilder
(mockWebServer).interceptors(new HttpConnectionResponseInterceptor() {
// This interceptor responds to our 444 request with a retry
@Override
public HttpConnectionInterceptorContext interceptResponse
(HttpConnectionInterceptorContext context) {
try {
if (444 == context.connection.getConnection().getResponseCode()) {
context.replayRequest = true;
// Close the error stream
InputStream errors = context.connection.getConnection().getErrorStream();
if (errors != null) {
IOUtils.closeQuietly(errors);
}
}
} catch (IOException e) {
e.printStackTrace();
fail("IOException getting response code in test interceptor");
}
return context;
}
}).build().executeRequest(request);
String responseStr = response.responseAsString();
assertTrue(responseStr.isEmpty(), "There should be no response body on the mock response");
assertEquals(200, response.getConnection()
.getResponseCode(), "The final response code should be 200");
// We want the second request
assertEquals(2, mockWebServer.getRequestCount(), "There should have been two requests");
MockWebServerResources.takeRequestWithTimeout(mockWebServer);
RecordedRequest rr = MockWebServerResources.takeRequestWithTimeout(mockWebServer);
assertNotNull(rr, "The request should have been recorded");
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream((int) rr
.getBodySize());
rr.getBody().copyTo(byteArrayOutputStream);
assertArrayEquals(expectedContent,
byteArrayOutputStream.toByteArray(), "The body bytes should have matched after a " +
"retry");
}
@TestTemplate
public void testCookieRenewOnPost() throws Exception {
mockWebServer.enqueue(OK_COOKIE);
mockWebServer.enqueue(new MockResponse().setResponseCode(403).setBody
("{\"error\":\"credentials_expired\", \"reason\":\"Session expired\"}\r\n"));
mockWebServer.enqueue(OK_COOKIE);
mockWebServer.enqueue(new MockResponse());
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.username("a")
.password("b")
.build();
HttpConnection request = Http.POST(mockWebServer.url("/").url(), "application/json");
request.setRequestBody("{\"some\": \"json\"}");
HttpConnection response = c.executeRequest(request);
String responseStr = response.responseAsString();
assertTrue(responseStr.isEmpty(), "There should be no response body on the mock response");
response.getConnection().getResponseCode();
}
@TestTemplate
public void testCustomHeader() throws Exception {
mockWebServer.enqueue(new MockResponse());
final String headerName = "Test-Header";
final String headerValue = "testHeader";
CloudantClient client = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.interceptors(new HttpConnectionRequestInterceptor() {
@Override
public HttpConnectionInterceptorContext interceptRequest
(HttpConnectionInterceptorContext context) {
context.connection.requestProperties.put(headerName, headerValue);
return context;
}
}).build();
client.getAllDbs();
assertEquals(1, mockWebServer.getRequestCount(), "There should have been 1 request");
RecordedRequest request = MockWebServerResources.takeRequestWithTimeout(mockWebServer);
assertNotNull(request, "The recorded request should not be null");
assertNotNull(request.getHeader(headerName), "The custom header should have been present");
assertEquals(headerValue, request
.getHeader(headerName), "The custom header should have the specified value");
}
/**
* Test that chunking is used when input stream length is not known.
*
* @throws Exception
*/
@TestTemplate
public void testChunking() throws Exception {
mockWebServer.enqueue(new MockResponse());
final int chunkSize = 1024 * 8;
final int chunks = 50 * 4;
CloudantClient client = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.build();
// POST some large random data
String response = client.executeRequest(Http.POST(mockWebServer.url("/").url(),
"text/plain")
.setRequestBody(new RandomInputStreamGenerator(chunks * chunkSize)))
.responseAsString();
assertTrue(response.isEmpty(), "There should be no response body on the mock response");
assertEquals(1, mockWebServer
.getRequestCount(), "There should have been 1 request");
RecordedRequest request = MockWebServerResources.takeRequestWithTimeout(mockWebServer);
assertNotNull(request, "The recorded request should not be null");
assertNull(request.getHeader("Content-Length"), "There should be no Content-Length header");
assertEquals("chunked", request.getHeader
("Transfer-Encoding"), "The Transfer-Encoding should be chunked");
// It would be nice to assert that we got the chunk sizes we were expecting, but sadly the
// HttpURLConnection and ChunkedOutputStream only use the chunkSize as a suggestion and seem
// to use the buffer size instead. The best assertion we can make is that we did receive
// multiple chunks.
assertTrue(request.getChunkSizes().size() > 1, "There should have been at least 2 chunks");
}
private static final class RandomInputStreamGenerator implements HttpConnection
.InputStreamGenerator {
final byte[] content;
RandomInputStreamGenerator(int sizeOfRandomContent) {
// Construct a byte array of random data
content = new byte[sizeOfRandomContent];
new java.util.Random().nextBytes(content);
}
@Override
public InputStream getInputStream() throws IOException {
return new ByteArrayInputStream(content);
}
}
/**
* Test that a request is replayed in response to a 429.
*
* @throws Exception
*/
@TestTemplate
public void test429Backoff() throws Exception {
mockWebServer.enqueue(MockWebServerResources.get429());
mockWebServer.enqueue(new MockResponse());
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.interceptors(Replay429Interceptor.WITH_DEFAULTS)
.build();
String response = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
assertTrue(response.isEmpty(), "There should be no response body on the mock response");
assertEquals(2, mockWebServer.getRequestCount(), "There should be 2 requests");
}
/**
* Test that the default maximum number of retries is reached and the backoff is of at least the
* expected duration.
*
* @throws Exception
*/
@TestTemplate
public void test429BackoffMaxDefault() throws Exception {
// Always respond 429 for this test
mockWebServer.setDispatcher(MockWebServerResources.ALL_429);
TestTimer t = TestTimer.startTimer();
try {
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.interceptors(Replay429Interceptor.WITH_DEFAULTS)
.build();
String response = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
fail("There should be a TooManyRequestsException instead had response " + response);
} catch (TooManyRequestsException e) {
long duration = t.stopTimer(TimeUnit.MILLISECONDS);
// 3 backoff periods for 4 attempts: 250 + 500 + 1000 = 1750 ms
assertTrue(duration >=
1750, "The duration should be at least 1750 ms, but was " + duration);
assertEquals(4, mockWebServer
.getRequestCount(), "There should be 4 request attempts");
}
}
/**
* Test that the configured maximum number of retries is reached and the backoff is of at least
* the expected duration.
*
* @throws Exception
*/
@TestTemplate
public void test429BackoffMaxConfigured() throws Exception {
// Always respond 429 for this test
mockWebServer.setDispatcher(MockWebServerResources.ALL_429);
TestTimer t = TestTimer.startTimer();
try {
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.interceptors(new Replay429Interceptor(10, 1, true))
.build();
String response = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
fail("There should be a TooManyRequestsException instead had response " + response);
} catch (TooManyRequestsException e) {
long duration = t.stopTimer(TimeUnit.MILLISECONDS);
// 9 backoff periods for 10 attempts: 1 + 2 + 4 + 8 + 16 + 32 + 64 + 128 + 256 = 511 ms
assertTrue(duration >=
511, "The duration should be at least 511 ms, but was " + duration);
assertEquals(10, mockWebServer
.getRequestCount(), "There should be 10 request attempts");
}
}
/**
* Test that the outer number of configured retries takes precedence.
*
* @throws Exception
*/
@TestTemplate
public void test429BackoffMaxMoreThanRetriesAllowed() throws Exception {
// Always respond 429 for this test
mockWebServer.setDispatcher(MockWebServerResources.ALL_429);
try {
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.interceptors(new Replay429Interceptor(10, 1, true))
.build();
String response = c.executeRequest(Http.GET(c.getBaseUri()).setNumberOfRetries(3))
.responseAsString();
fail("There should be a TooManyRequestsException instead had response " + response);
} catch (TooManyRequestsException e) {
assertEquals(3, mockWebServer
.getRequestCount(), "There should be 3 request attempts");
}
}
/**
* Test that an integer number of seconds delay specified by a Retry-After header is honoured.
*
* @throws Exception
*/
@TestTemplate
public void test429BackoffRetryAfter() throws Exception {
mockWebServer.enqueue(MockWebServerResources.get429().addHeader("Retry-After", "1"));
mockWebServer.enqueue(new MockResponse());
TestTimer t = TestTimer.startTimer();
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.interceptors(Replay429Interceptor.WITH_DEFAULTS)
.build();
String response = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
assertTrue(response.isEmpty(), "There should be no response body on the mock response");
long duration = t.stopTimer(TimeUnit.MILLISECONDS);
assertTrue(duration >=
1000, "The duration should be at least 1000 ms, but was " + duration);
assertEquals(2, mockWebServer
.getRequestCount(), "There should be 2 request attempts");
}
@TestTemplate
public void test429IgnoreRetryAfter() throws Exception {
mockWebServer.enqueue(MockWebServerResources.get429().addHeader("Retry-After", "1"));
mockWebServer.enqueue(new MockResponse());
TestTimer t = TestTimer.startTimer();
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.interceptors(new Replay429Interceptor(1, 1, false))
.build();
String response = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
assertTrue(response.isEmpty(), "There should be no response body on the mock response");
long duration = t.stopTimer(TimeUnit.MILLISECONDS);
assertTrue(duration <
1000, "The duration should be less than 1000 ms, but was " + duration);
assertEquals(2, mockWebServer
.getRequestCount(), "There should be 2 request attempts");
}
/**
* Test the global number of retries
*
* @throws Exception
*/
@TestTemplate
public void testHttpConnectionRetries() throws Exception {
// Just return 200 OK
mockWebServer.setDispatcher(new MockWebServerResources.ConstantResponseDispatcher(200));
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.interceptors(new HttpConnectionResponseInterceptor() {
@Override
public HttpConnectionInterceptorContext interceptResponse
(HttpConnectionInterceptorContext context) {
// At least do something with the connection, otherwise we risk breaking it
try {
context.connection.getConnection().getResponseCode();
} catch (IOException e) {
fail("IOException getting response code");
}
// Set to always replay
context.replayRequest = true;
return context;
}
})
.build();
String response = c.executeRequest(Http.GET(c.getBaseUri()).setNumberOfRetries(5))
.responseAsString();
assertTrue(response.isEmpty(), "There should be no response body on the mock response");
assertEquals(5, mockWebServer
.getRequestCount(), "There should be 5 request attempts");
}
/**
* Test that an IllegalArgumentException is thrown if a https proxy address is used. SSL
* proxies are not supported. HTTP proxies can tunnel SSL connections to HTTPS servers.
*
* @throws Exception
*/
@TestTemplate
public void httpsProxyIllegalArgumentException() throws Exception {
assertThrows(IllegalArgumentException.class, new Executable() {
@Override
public void execute() throws Throwable {
// Get a client pointing to an https proxy
CloudantClient client = CloudantClientHelper.newMockWebServerClientBuilder
(mockWebServer)
.proxyURL(new URL("https://192.0.2.0")).build();
String response = client.executeRequest(Http.GET(client.getBaseUri()))
.responseAsString();
fail("There should be an IllegalStateException for an https proxy.");
}
});
}
/**
* Test that the stored cookie is applied to requests for different URLs. Most of the other
* tests just check a single URL.
*
* @throws Exception
*/
@TestTemplate
public void cookieAppliedToDifferentURL() throws Exception {
mockWebServer.enqueue(OK_COOKIE);
mockWebServer.enqueue(new MockResponse().setBody("first"));
mockWebServer.enqueue(new MockResponse().setBody("second"));
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.username("a")
.password("b")
.build();
URI baseURI = c.getBaseUri();
URL first = new URL(baseURI.getScheme(), baseURI.getHost(), baseURI.getPort(), "/testdb");
String response = c.executeRequest(Http.GET(first)).responseAsString();
assertEquals("first", response, "The correct response body should be present");
// There should be a request for a cookie followed by a the real request
assertEquals(2, mockWebServer.getRequestCount(), "There should be 2 requests");
assertEquals("/_session", MockWebServerResources.takeRequestWithTimeout(mockWebServer)
.getPath(), "The first request should have been for a cookie");
RecordedRequest request = MockWebServerResources.takeRequestWithTimeout(mockWebServer);
assertEquals("/testdb",
request.getPath(), "The second request should have been for /testdb");
assertNotNull(request.getHeader("Cookie"), "There should be a cookie on the request");
// Now make a request to another URL
URL second = new URL(baseURI.getScheme(), baseURI.getHost(), baseURI.getPort(),
"/_all_dbs");
response = c.executeRequest(Http.GET(second)).responseAsString();
assertEquals("second", response, "The correct response body should be present");
// There should now be an additional request
assertEquals(3, mockWebServer.getRequestCount(), "There should be 3 requests");
request = MockWebServerResources.takeRequestWithTimeout(mockWebServer);
assertEquals("/_all_dbs", request.getPath(), "The second request should have been for " +
"/_all_dbs");
String cookieHeader = request.getHeader("Cookie");
assertNotNull(cookieHeader, "There should be a cookie on the request");
assertTrue(
request.getHeader("Cookie").contains(EXPECTED_OK_COOKIE), "The cookie header " +
cookieHeader + " should contain the expected value.");
}
/**
* Test that cookie authentication throws a CouchDbException if the credentials were bad.
*
* @throws Exception
*/
@TestTemplate
public void badCredsCookieThrows() {
mockWebServer.enqueue(new MockResponse().setResponseCode(401));
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.username("bad")
.password("worse")
.build();
CouchDbException re =
assertThrows(CouchDbException.class,
() -> c.executeRequest(Http.GET(c.getBaseUri())).responseAsString(),
"Bad credentials should throw a CouchDbException.");
assertTrue(re.getMessage().startsWith("401 Credentials are incorrect for server"), "The " +
"exception should have been for bad creds.");
}
/**
* Test that having no body and hence no error stream on a 403 response correctly results in the
* 403 causing a CouchDbException with no NPEs.
*
* @throws Exception
*/
@TestTemplate
public void noErrorStream403() throws Exception {
assertThrows(CouchDbException.class, new Executable() {
@Override
public void execute() throws Throwable {
// Respond with a cookie init to the first request to _session
mockWebServer.enqueue(OK_COOKIE);
// Respond to the executeRequest GET of / with a 403 with no body
mockWebServer.enqueue(new MockResponse().setResponseCode(403));
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.username("a")
.password("b")
.build();
String response = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
fail("There should be an exception, but received response " + response);
}
});
}
/**
* Test that having no body and hence no error stream on a 401 response correctly results in a
* 401 cookie renewal without a NPE.
*
* @throws Exception
*/
@TestTemplate
public void noErrorStream401() throws Exception {
// Respond with a cookie init to the first request to _session
mockWebServer.enqueue(OK_COOKIE);
// Respond to the executeRequest GET of / with a 401 with no body
mockWebServer.enqueue(new MockResponse().setResponseCode(401));
// 401 triggers a renewal so respond with a new cookie for renewal request to _session
mockWebServer.enqueue(OK_COOKIE);
// Finally respond 200 OK with body of "TEST" to the replay of GET to /
mockWebServer.enqueue(new MockResponse().setBody("TEST"));
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.username("a")
.password("b")
.build();
String response = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
assertEquals("TEST", response, "The expected response body should be received");
}
/**
* This test checks that only a single session renewal request is made on expiry.
* Flow:
* - First request to _all_dbs
* - sends a _session request and gets OK_COOKIE
* - _all_dbs returns ["a"]
* - Multi-threaded requests to root endpoint
* - Any that occur before session renewal get a 401 unauthorized and try to renew the session
* - a _session request will return OK_COOKIE_2 but can only be invoked once for test purposes
* - any requests after session renewal will get an OK response
*
* @throws Exception
*/
@TestTemplate
public void singleSessionRequestOnExpiry() throws Exception {
final AtomicInteger sessionCounter = new AtomicInteger();
mockWebServer.setDispatcher(new Dispatcher() {
// Use 444 response for error cases as we know this will get an exception without retries
private final MockResponse FAIL = new MockResponse().setStatus("HTTP/1.1 444 session locking fail");
@Override
public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
if (request.getPath().endsWith("_session")) {
int session = sessionCounter.incrementAndGet();
switch (session) {
case 1:
return OK_COOKIE;
case 2:
return OK_COOKIE_2;
default:
return FAIL;
}
} else if (request.getPath().endsWith("_all_dbs")) {
return new MockResponse().setBody("[\"a\"]");
} else {
String cookie = request.getHeader("COOKIE");
if (cookie.contains(EXPECTED_OK_COOKIE)) {
// Request in first session
return new MockResponse().setResponseCode(401);
} else if (cookie.contains(EXPECTED_OK_COOKIE_2)) {
// Request in second session, return OK
return new MockResponse();
} else {
return FAIL;
}
}
}
});
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.username("a")
.password("b")
.build();
// Do a single request to start the first session
c.getAllDbs();
// Now run lots of requests simultaneously
int threads = 25;
int requests = 1250;
ExecutorService executorService = Executors.newFixedThreadPool(threads);
List tasks = new ArrayList(requests);
for (int i = 0; i < requests; i++) {
tasks.add(new ServerInfoCallable(c));
}
List> results = executorService.invokeAll(tasks);
for (Future result : results) {
assertNull(result.get(), "There should be no exceptions.");
}
assertEquals(2, sessionCounter.get(), "There should only be 2 session requests");
}
private final class ServerInfoCallable implements Callable {
private final CloudantClient c;
ServerInfoCallable(CloudantClient c) {
this.c = c;
}
@Override
public Throwable call() {
try {
c.metaInformation();
} catch (Throwable t) {
return t;
}
return null;
}
}
/**
* Tests that loading the OkHelper will not try to use any classes from outside the
* cloudant-http built classes. Specifically it won't cause any okhttp classes to try to be
* loaded outside of the controlled check for the presence of okhttp.
*
* @throws ClassNotFoundException if the load tries to use any classes it shouldn't
* @throws Exception if there is another issue in the test
*/
@TestTemplate
public void okUsableClassLoad() throws ClassNotFoundException, Exception {
// Point to the built classes, it's a bit awkward but we need to load the class cleanly
File f = new File("../cloudant-http/build/classes/java/main/");
ClassLoader loader = new CloudantHttpIsolationClassLoader(new URL[]{f.toURI().toURL()});
Class> okHelperClass = Class.forName("com.cloudant.http.internal.ok.OkHelper"
, true, loader);
assertEquals(isOkUsable, okHelperClass.getMethod
("isOkUsable").invoke(null), "The isOkUsable value should be correct");
}
/**
* A simple classloader that has a null parent classloader when testing okUsable false. This
* isolates classes loaded with this classloader from the test classpath and hence any
* attempt to load a class from the okhttp library will result in a NoClassDefFound.
*/
public class CloudantHttpIsolationClassLoader extends URLClassLoader {
public CloudantHttpIsolationClassLoader(URL[] urls) {
// If we are testing okhttp then allow the parent classloader, otherwise use null
// to isolate okhttp classes from the test load
super(urls, isOkUsable ? CloudantHttpIsolationClassLoader.class.getClassLoader() :
null);
}
@Override
protected Class> findClass(String name) throws ClassNotFoundException {
return super.findClass(name);
}
}
// helper - assert that _n_ requests were made on the mock server and return them in an array
public static RecordedRequest[] takeN(MockWebServer server, int n) throws Exception {
assertEquals(n,
server.getRequestCount(), String.format(Locale.ENGLISH, "The server should have " +
"%d received requests", n));
RecordedRequest[] recordedRequests = new RecordedRequest[n];
for (int i = 0; i < n; i++) {
recordedRequests[i] = MockWebServerResources.takeRequestWithTimeout(server);
}
return recordedRequests;
}
/**
* Test that a response stream from a successful _session request is consumed and closed.
*
* @throws Exception
*/
@TestTemplate
public void successfulSessionStreamClose() throws Exception {
// Queue a mock response for the _session request
mockWebServer.enqueue(OK_COOKIE);
// Queue a mock response for an _all_dbs request
mockWebServer.enqueue(new MockResponse().setBody("[]"));
final AtomicReference responseStream = new AtomicReference();
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
.interceptors(new HttpConnectionResponseInterceptor() {
@Override
public HttpConnectionInterceptorContext interceptResponse(HttpConnectionInterceptorContext context) {
try {
// Store the response stream from the session request
if (context.connection.url.toString().contains("_session")) {
HttpURLConnection conn = context.connection.getConnection();
responseStream.set(context.connection.getConnection().getInputStream());
}
} catch (IOException e) {
fail("IOException in test interceptor.");
}
return context;
}
})
.username("a")
.password("b")
.build();
// Make a request to init the session
List dbs = c.getAllDbs();
// Get the response stream from the session request
InputStream stream = responseStream.get();
// Assert that the stream has been consumed
assertEquals(0, stream.available(), "There should be no bytes available from the stream.");
// Assert that the stream is closed
assertThrows(IOException.class,
() -> stream.read(),
"Reading the stream should throw an IOException because the stream should be " +
"closed.");
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy