Skip to content
This repository was archived by the owner on Mar 11, 2022. It is now read-only.

Commit 9851295

Browse files
committed
New tests for session overlap and token retry
Added a test for multiple threads expriencing session expiry and trying to renew, asserting that only one renewal attempt is made. Added IAM token 429 retry tests for success and failure cases.
1 parent 62b5690 commit 9851295

3 files changed

Lines changed: 262 additions & 52 deletions

File tree

cloudant-client/src/test/java/com/cloudant/tests/HttpIamTest.java

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import com.cloudant.client.api.CloudantClient;
3939
import com.cloudant.client.org.lightcouch.CouchDbException;
4040
import com.cloudant.http.Http;
41+
import com.cloudant.http.interceptors.Replay429Interceptor;
4142
import com.cloudant.tests.extensions.MockWebServerExtension;
4243
import com.cloudant.tests.util.IamSystemPropertyMock;
4344
import com.cloudant.tests.util.MockWebServerResources;
@@ -322,7 +323,6 @@ public void iamRenewalFailureOnIamToken() throws Exception {
322323
assertThrows(CouchDbException.class,
323324
() -> c.executeRequest(Http.GET(c.getBaseUri())).responseAsString(),
324325
"Failure to get a token should throw a CouchDbException.");
325-
re.printStackTrace();
326326
assertTrue(re.getMessage().startsWith("HTTP response error getting session"), "The " +
327327
"exception should have been for a HTTP response error.");
328328

@@ -458,5 +458,166 @@ public void iamRenewalFailureOnSessionCookie() throws Exception {
458458
"/identity/token");
459459
}
460460

461+
@Test
462+
public void iamTokenServer429RetryAndSucceed() throws Exception {
463+
// Mock request sequence
464+
mockIamServer.enqueue(new MockResponse().setResponseCode(200).setBody(IAM_TOKEN));
465+
mockWebServer.enqueue(OK_IAM_COOKIE);
466+
// First get request succeeds
467+
mockWebServer.enqueue(new MockResponse().setResponseCode(200)
468+
.setBody(hello));
469+
470+
// Second get request has a 401 cookie expired
471+
mockWebServer.enqueue(new MockResponse().setResponseCode(401).
472+
setBody("{\"error\":\"credentials_expired\"}"));
473+
// IAM server 429 on token request
474+
mockIamServer.enqueue(new MockResponse().setStatus("HTTP/1.1 429 Too many requests"));
475+
// Success on retry
476+
mockIamServer.enqueue(new MockResponse().setResponseCode(200).setBody(IAM_TOKEN_2));
477+
mockWebServer.enqueue(OK_IAM_COOKIE_2);
478+
// Second get request suceeds after renewal
479+
mockWebServer.enqueue(new MockResponse().setResponseCode(200)
480+
.setBody(hello));
481+
482+
// Request sequence
483+
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
484+
.iamApiKey(IAM_API_KEY)
485+
.interceptors(Replay429Interceptor.WITH_DEFAULTS)
486+
.build();
487+
488+
String response = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
489+
assertEquals(hello, response, "The expected response should be received");
490+
491+
String response2 = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
492+
assertEquals(hello, response2, "The expected response should be received");
493+
494+
// iam mock server
495+
496+
// assert that there were 3 calls
497+
RecordedRequest[] recordedIamRequests = takeN(mockIamServer, 3);
498+
// first time, automatically fetch because cookie jar is empty
499+
assertEquals(iamTokenEndpoint,
500+
recordedIamRequests[0].getPath(), "The request should have been for " +
501+
"/identity/token");
502+
assertThat("The request body should contain the IAM API key",
503+
recordedIamRequests[0].getBody().readString(Charset.forName("UTF-8")),
504+
containsString("apikey=" + IAM_API_KEY));
505+
// second time, 429 response
506+
assertEquals(iamTokenEndpoint,
507+
recordedIamRequests[1].getPath(), "The request should have been for " +
508+
"/identity/token");
509+
// third time, refresh because the cloudant session cookie has expired
510+
assertEquals(iamTokenEndpoint,
511+
recordedIamRequests[1].getPath(), "The request should have been for " +
512+
"/identity/token");
513+
514+
// cloudant mock server
515+
516+
// assert that there were 5 calls
517+
RecordedRequest[] recordedRequests = takeN(mockWebServer, 5);
518+
519+
assertEquals("/_iam_session",
520+
recordedRequests[0].getPath(), "The request should have been for /_iam_session");
521+
assertThat("The request body should contain the IAM token",
522+
recordedRequests[0].getBody().readString(Charset.forName("UTF-8")),
523+
containsString(IAM_TOKEN));
524+
// first request
525+
assertEquals("/",
526+
recordedRequests[1].getPath(), "The request should have been for /");
527+
// The cookie may or may not have the session id quoted, so check both
528+
assertThat("The Cookie header should contain the expected session value",
529+
recordedRequests[1].getHeader("Cookie"),
530+
anyOf(containsString(iamSession(EXPECTED_OK_COOKIE)),
531+
containsString(iamSessionUnquoted(EXPECTED_OK_COOKIE))));
532+
// second request (rejected for cookie expiry)
533+
assertEquals("/",
534+
recordedRequests[2].getPath(), "The request should have been for /");
535+
// with new valid token get a new session
536+
assertEquals("/_iam_session",
537+
recordedRequests[3].getPath(), "The request should have been for /_iam_session");
538+
assertThat("The request body should contain the IAM token",
539+
recordedRequests[3].getBody().readString(Charset.forName("UTF-8")),
540+
containsString(IAM_TOKEN_2));
541+
assertEquals("/",
542+
recordedRequests[2].getPath(), "The request should have been for /");
543+
}
544+
545+
@Test
546+
public void iamTokenServer429RetryAndFail() throws Exception {
547+
// Mock request sequence
548+
mockIamServer.enqueue(new MockResponse().setResponseCode(200).setBody(IAM_TOKEN));
549+
mockWebServer.enqueue(OK_IAM_COOKIE);
550+
// First get request succeeds
551+
mockWebServer.enqueue(new MockResponse().setResponseCode(200)
552+
.setBody(hello));
553+
554+
// Second get request has a 401 cookie expired
555+
mockWebServer.enqueue(new MockResponse().setResponseCode(401).
556+
setBody("{\"error\":\"credentials_expired\"}"));
557+
// IAM server 429 on subsequent token requests
558+
mockIamServer.enqueue(new MockResponse().setStatus("HTTP/1.1 429 Too many requests"));
559+
mockIamServer.enqueue(new MockResponse().setStatus("HTTP/1.1 429 Too many requests"));
560+
mockIamServer.enqueue(new MockResponse().setStatus("HTTP/1.1 429 Too many requests"));
561+
mockIamServer.enqueue(new MockResponse().setStatus("HTTP/1.1 429 Too many requests"));
562+
// Request will fail
563+
564+
// Request sequence
565+
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
566+
.iamApiKey(IAM_API_KEY)
567+
.interceptors(Replay429Interceptor.WITH_DEFAULTS)
568+
.build();
461569

570+
String response = c.executeRequest(Http.GET(c.getBaseUri())).responseAsString();
571+
assertEquals(hello, response, "The expected response should be received");
572+
573+
CouchDbException re =
574+
assertThrows(CouchDbException.class,
575+
() -> c.executeRequest(Http.GET(c.getBaseUri())).responseAsString(),
576+
"Failure to get a token should throw a CouchDbException.");
577+
assertTrue(re.getMessage().startsWith("HTTP response error getting session"), "The " +
578+
"exception should have been for a HTTP response error.");
579+
assertTrue(re.getMessage().contains("response code 429"), "The exception should report a " +
580+
"429 response code");
581+
582+
// iam mock server
583+
584+
// assert that there were 5 calls
585+
RecordedRequest[] recordedIamRequests = takeN(mockIamServer, 5);
586+
// first time, automatically fetch because cookie jar is empty
587+
assertEquals(iamTokenEndpoint,
588+
recordedIamRequests[0].getPath(), "The request should have been for " +
589+
"/identity/token");
590+
assertThat("The request body should contain the IAM API key",
591+
recordedIamRequests[0].getBody().readString(Charset.forName("UTF-8")),
592+
containsString("apikey=" + IAM_API_KEY));
593+
// 4 more times all 429 responses
594+
for (int i = 1; i <= 4; i++) {
595+
assertEquals(iamTokenEndpoint,
596+
recordedIamRequests[i].getPath(), "The request[" + i + "] should have been " +
597+
"for " +
598+
"/identity/token");
599+
}
600+
601+
// cloudant mock server
602+
603+
// assert that there were 3 calls
604+
RecordedRequest[] recordedRequests = takeN(mockWebServer, 3);
605+
606+
assertEquals("/_iam_session",
607+
recordedRequests[0].getPath(), "The request should have been for /_iam_session");
608+
assertThat("The request body should contain the IAM token",
609+
recordedRequests[0].getBody().readString(Charset.forName("UTF-8")),
610+
containsString(IAM_TOKEN));
611+
// first request
612+
assertEquals("/",
613+
recordedRequests[1].getPath(), "The request should have been for /");
614+
// The cookie may or may not have the session id quoted, so check both
615+
assertThat("The Cookie header should contain the expected session value",
616+
recordedRequests[1].getHeader("Cookie"),
617+
anyOf(containsString(iamSession(EXPECTED_OK_COOKIE)),
618+
containsString(iamSessionUnquoted(EXPECTED_OK_COOKIE))));
619+
// Second request gives a 401 that starts token renewal
620+
// Retry of that request never reaches the server
621+
assertEquals("/", recordedRequests[1].getPath(), "The request should have been for /");
622+
}
462623
}

cloudant-client/src/test/java/com/cloudant/tests/HttpTest.java

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,16 @@
8686
import java.net.URLClassLoader;
8787
import java.net.URLDecoder;
8888
import java.nio.charset.Charset;
89+
import java.util.ArrayList;
8990
import java.util.Collections;
9091
import java.util.List;
9192
import java.util.Locale;
93+
import java.util.concurrent.Callable;
94+
import java.util.concurrent.ExecutorService;
95+
import java.util.concurrent.Executors;
96+
import java.util.concurrent.Future;
9297
import java.util.concurrent.TimeUnit;
98+
import java.util.concurrent.atomic.AtomicInteger;
9399
import java.util.regex.Pattern;
94100
import java.util.stream.Stream;
95101

@@ -1103,6 +1109,100 @@ public void noErrorStream401() throws Exception {
11031109
assertEquals("TEST", response, "The expected response body should be received");
11041110
}
11051111

1112+
/**
1113+
* This test checks that only a single session renewal request is made on expiry.
1114+
* Flow:
1115+
* - First request to _all_dbs
1116+
* - sends a _session request and gets OK_COOKIE
1117+
* - _all_dbs returns ["a"]
1118+
* - Multi-threaded requests to root endpoint
1119+
* - Any that occur before session renewal get a 401 unauthorized and try to renew the session
1120+
* - a _session request will return OK_COOKIE_2 but can only be invoked once for test purposes
1121+
* - any requests after session renewal will get an OK response
1122+
*
1123+
* @throws Exception
1124+
*/
1125+
@TestTemplate
1126+
public void singleSessionRequestOnExpiry() throws Exception {
1127+
final AtomicInteger sessionCounter = new AtomicInteger();
1128+
mockWebServer.setDispatcher(new Dispatcher() {
1129+
1130+
// Use 444 response for error cases as we know this will get an exception without retries
1131+
private final MockResponse FAIL = new MockResponse().setStatus("HTTP/1.1 444 session locking fail");
1132+
1133+
@Override
1134+
public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
1135+
if (request.getPath().endsWith("_session")) {
1136+
int session = sessionCounter.incrementAndGet();
1137+
switch (session) {
1138+
case 1:
1139+
return OK_COOKIE;
1140+
case 2:
1141+
return OK_COOKIE_2;
1142+
default:
1143+
return FAIL;
1144+
}
1145+
} else if (request.getPath().endsWith("_all_dbs")) {
1146+
return new MockResponse().setBody("[\"a\"]");
1147+
} else {
1148+
String cookie = request.getHeader("COOKIE");
1149+
if (cookie.contains(EXPECTED_OK_COOKIE)) {
1150+
// Request in first session
1151+
return new MockResponse().setResponseCode(401);
1152+
} else if (cookie.contains(EXPECTED_OK_COOKIE_2)) {
1153+
// Request in second session, return OK
1154+
return new MockResponse();
1155+
} else {
1156+
return FAIL;
1157+
}
1158+
}
1159+
}
1160+
});
1161+
1162+
CloudantClient c = CloudantClientHelper.newMockWebServerClientBuilder(mockWebServer)
1163+
.username("a")
1164+
.password("b")
1165+
.build();
1166+
1167+
// Do a single request to start the first session
1168+
c.getAllDbs();
1169+
1170+
// Now run lots of requests simultaneously
1171+
int threads = 25;
1172+
int requests = 1250;
1173+
1174+
ExecutorService executorService = Executors.newFixedThreadPool(threads);
1175+
1176+
List<ServerInfoCallable> tasks = new ArrayList<ServerInfoCallable>(requests);
1177+
for (int i = 0; i < requests; i++) {
1178+
tasks.add(new ServerInfoCallable(c));
1179+
}
1180+
List<Future<Throwable>> results = executorService.invokeAll(tasks);
1181+
for (Future<Throwable> result : results) {
1182+
assertNull(result.get(), "There should be no exceptions.");
1183+
}
1184+
assertEquals(2, sessionCounter.get(), "There should only be 2 session requests");
1185+
}
1186+
1187+
private final class ServerInfoCallable implements Callable<Throwable> {
1188+
1189+
private final CloudantClient c;
1190+
1191+
ServerInfoCallable(CloudantClient c) {
1192+
this.c = c;
1193+
}
1194+
1195+
@Override
1196+
public Throwable call() {
1197+
try {
1198+
c.metaInformation();
1199+
} catch (Throwable t) {
1200+
return t;
1201+
}
1202+
return null;
1203+
}
1204+
}
1205+
11061206
/**
11071207
* Tests that loading the OkHelper will not try to use any classes from outside the
11081208
* cloudant-http built classes. Specifically it won't cause any okhttp classes to try to be

cloudant-client/src/test/java/com/cloudant/tests/util/IamSystemPropertyMock.java

Lines changed: 0 additions & 51 deletions
This file was deleted.

0 commit comments

Comments
 (0)