blob: 4a4f4d58f0f8ee4e16f5a6595be1536e42ba2aab [file] [log] [blame]
// Copyright 2017 Google Inc.
//
// 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.google.crypto.tink.util;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import com.google.api.client.http.HttpStatusCodes;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.LowLevelHttpRequest;
import com.google.api.client.testing.http.MockHttpTransport;
import com.google.api.client.testing.http.MockLowLevelHttpRequest;
import com.google.api.client.testing.http.MockLowLevelHttpResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.FutureTask;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
/** Tests for {@link KeysDownloader}. */
@RunWith(JUnit4.class)
public class KeysDownloaderTest {
private static final long INITIAL_CURRENT_TIME_IN_MILLIS = 1000;
private CountDownLatch backgroundFetchFinishedLatch;
private CountDownLatch delayHttpResponseLatch;
private ExecutorService executor;
private HttpResponseBuilder httpResponseBuilder;
private AtomicInteger backgroundFetchStartedCount;
private AtomicInteger httpTransportGetCount;
private boolean executorIsAcceptingRunnables;
private long currentTimeInMillis;
@Before
public void setUp() {
backgroundFetchFinishedLatch = new CountDownLatch(1);
delayHttpResponseLatch = null;
executor = Executors.newCachedThreadPool();
httpResponseBuilder = new HttpResponseBuilder();
backgroundFetchStartedCount = new AtomicInteger(0);
httpTransportGetCount = new AtomicInteger(0);
currentTimeInMillis = INITIAL_CURRENT_TIME_IN_MILLIS;
executorIsAcceptingRunnables = true;
TestKeysDownloader.sTestInstance = this;
}
@After
public void tearDown() throws Exception {
executor.shutdownNow();
assertTrue(
"Timed out while waiting for the threadpool to terminate!",
executor.awaitTermination(1, TimeUnit.SECONDS));
}
@Test
public void builderShouldThrowIllegalArgumentExceptionWhenUrlIsNotHttps() {
try {
new KeysDownloader.Builder().setUrl("http://abc").build();
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException ex) {
// expected.
}
}
@Test
public void shouldFetchKeys() throws Exception {
httpResponseBuilder = new HttpResponseBuilder().setContent("keys");
assertEquals("keys", newInstanceForTests().download());
}
@Test
public void shouldThrowOnSuccessHttpResponsesThatAreNotOk() throws Exception {
httpResponseBuilder =
new HttpResponseBuilder().setStatusCode(HttpStatusCodes.STATUS_CODE_NO_CONTENT);
KeysDownloader instance = newInstanceForTests();
try {
instance.download();
fail();
} catch (IOException expected) {
assertEquals(
"Unexpected status code = " + HttpStatusCodes.STATUS_CODE_NO_CONTENT,
expected.getMessage());
}
}
@Test
public void shouldThrowOnNonSuccessHttpResponses() throws Exception {
httpResponseBuilder =
new HttpResponseBuilder().setStatusCode(HttpStatusCodes.STATUS_CODE_NO_CONTENT);
KeysDownloader instance = newInstanceForTests();
try {
instance.download();
fail();
} catch (IOException expected) {
assertTrue(
"Message "
+ expected.getMessage()
+ " should contain "
+ HttpStatusCodes.STATUS_CODE_NO_CONTENT,
expected.getMessage().contains(Integer.toString(HttpStatusCodes.STATUS_CODE_NO_CONTENT)));
}
}
@Test
public void shouldCacheKeysOnFetches() throws Exception {
KeysDownloader instance = newInstanceForTests();
httpResponseBuilder = new HttpResponseBuilder().setContent("keys1");
// Fetched and cached keys
assertEquals("keys1", instance.download());
// Keys changed
httpResponseBuilder = new HttpResponseBuilder().setContent("keys2");
// Old keys are returned
assertEquals("keys1", instance.download());
}
@Test
public void shouldFetchKeysAgainIfNoCacheControlHeadersAreSent() throws Exception {
KeysDownloader instance = newInstanceForTests();
httpResponseBuilder = new HttpResponseBuilder().setContent("keys1").clearCacheControl();
// Fetched and cached keys
assertEquals("keys1", instance.download());
// Keys changed
httpResponseBuilder = new HttpResponseBuilder().setContent("keys2");
// New keys are fetched and returned
assertEquals("keys2", instance.download());
}
@Test
public void shouldFetchKeysAgainAfterExpiration() throws Exception {
KeysDownloader instance = newInstanceForTests();
httpResponseBuilder =
new HttpResponseBuilder().setContent("keys1").setCacheControlWithMaxAgeInSeconds(3L);
// Fetched and cached keys
assertEquals("keys1", instance.download());
// Keys changed
httpResponseBuilder = new HttpResponseBuilder().setContent("keys2");
// 3 seconds later ...
currentTimeInMillis += 3000L;
// New keys are fetched and returned
assertEquals("keys2", instance.download());
}
@Test
public void shouldReturnCachedKeysBeforeExpiration() throws Exception {
KeysDownloader instance = newInstanceForTests();
httpResponseBuilder =
new HttpResponseBuilder().setContent("keys1").setCacheControlWithMaxAgeInSeconds(3L);
// Fetched and cached keys
assertEquals("keys1", instance.download());
// Keys changed
httpResponseBuilder = new HttpResponseBuilder().setContent("keys2");
// 3 seconds - 1ms later ...
currentTimeInMillis += 3000L - 1;
// Old keys are sill returned
assertEquals("keys1", instance.download());
}
@Test
public void shouldFetchKeysAgainAfterExpirationAccountingForAgeHeader() throws Exception {
KeysDownloader instance = newInstanceForTests();
httpResponseBuilder =
new HttpResponseBuilder()
.setContent("keys1")
.setCacheControlWithMaxAgeInSeconds(3L)
.setAgeInSeconds(1L);
// Fetched and cached keys
assertEquals("keys1", instance.download());
// Keys changed
httpResponseBuilder = new HttpResponseBuilder().setContent("keys2");
// 2 seconds later ...
currentTimeInMillis += 2000L;
// New keys are fetched and returned
assertEquals("keys2", instance.download());
}
@Test
public void shouldReturnCachedKeysBeforeExpirationAccountingForAgeHeader() throws Exception {
KeysDownloader instance = newInstanceForTests();
httpResponseBuilder =
new HttpResponseBuilder()
.setContent("keys1")
.setCacheControlWithMaxAgeInSeconds(3L)
.setAgeInSeconds(1L);
// Fetched and cached keys
assertEquals("keys1", instance.download());
// Keys changed
httpResponseBuilder = new HttpResponseBuilder().setContent("keys2");
// 2 seconds - 1ms later ...
currentTimeInMillis += 2000L - 1;
// Old keys are sill returned
assertEquals("keys1", instance.download());
}
@Test
public void shouldTriggerBackgroundRefreshHalfWayThroughExpiration() throws Exception {
KeysDownloader instance = newInstanceForTests();
httpResponseBuilder =
new HttpResponseBuilder().setContent("keys1").setCacheControlWithMaxAgeInSeconds(3L);
// Fetched and cached keys
assertEquals("keys1", instance.download());
// Keys changed
httpResponseBuilder = new HttpResponseBuilder().setContent("keys2");
// 1.5 seconds later ...
currentTimeInMillis += 1500L;
// Old keys are sill returned, but a background fetch is initiated
assertEquals("keys1", instance.download());
// Wait background fetch to complete
waitForLatch(backgroundFetchFinishedLatch);
// 10ms later ...
currentTimeInMillis += 10;
// Keys changed again
httpResponseBuilder = new HttpResponseBuilder().setContent("keys3");
// Keys fetched in the background are used
assertEquals("keys2", instance.download());
// Single background fetch should have been triggered
assertEquals(1, backgroundFetchStartedCount.get());
}
@Test
public void shouldNotTriggerBackgroundRefreshBeforeHalfWayThroughExpiration() throws Exception {
KeysDownloader instance = newInstanceForTests();
httpResponseBuilder =
new HttpResponseBuilder().setContent("keys1").setCacheControlWithMaxAgeInSeconds(3L);
// Fetched and cached keys
assertEquals("keys1", instance.download());
// Keys changed
httpResponseBuilder = new HttpResponseBuilder().setContent("keys2");
// 1.5 seconds - 1ms later ...
currentTimeInMillis += 1500L - 1;
// Old keys are sill returned
assertEquals("keys1", instance.download());
// No background fetch should have been triggered
assertEquals(0, backgroundFetchStartedCount.get());
}
@Test
public void shouldPerformBackgroundRefreshWhenRequestedAndHaveCacheKeys() throws Exception {
KeysDownloader instance = newInstanceForTests();
httpResponseBuilder =
new HttpResponseBuilder().setContent("keys1").setCacheControlWithMaxAgeInSeconds(3L);
// Fetched and cache keys
instance.refreshInBackground();
// Wait background fetch to complete
waitForLatch(backgroundFetchFinishedLatch);
// Keys changed
httpResponseBuilder = new HttpResponseBuilder().setContent("keys2");
// Keys fetched in the background are used
assertEquals("keys1", instance.download());
// Single background fetch should have been triggered
assertEquals(1, backgroundFetchStartedCount.get());
// Single http fetch should have been triggered
assertEquals(1, httpTransportGetCount.get());
}
@Test
public void shouldPerformMultipleRefreshesWhenRequested() throws Exception {
KeysDownloader instance = newInstanceForTests();
httpResponseBuilder = new HttpResponseBuilder().setContent("keys1");
instance.refreshInBackground();
waitForLatch(backgroundFetchFinishedLatch);
httpResponseBuilder = new HttpResponseBuilder().setContent("keys2");
backgroundFetchFinishedLatch = new CountDownLatch(1);
instance.refreshInBackground();
waitForLatch(backgroundFetchFinishedLatch);
// Keys fetched in the background are used
assertEquals("keys2", instance.download());
// Multiple background fetch should have been triggered
assertEquals(2, backgroundFetchStartedCount.get());
// Multiple http fetch should have been triggered
assertEquals(2, httpTransportGetCount.get());
}
@Test
public void shouldPerformRefreshAfterExecutorTransientFailure() throws Exception {
KeysDownloader instance = newInstanceForTests();
httpResponseBuilder = new HttpResponseBuilder().setContent("keys1");
instance.download();
httpResponseBuilder = new HttpResponseBuilder().setContent("keys2");
// Executor temporarily full, rejecting new Runnable instances
executorIsAcceptingRunnables = false;
try {
instance.refreshInBackground();
fail();
} catch (RejectedExecutionException expected) {
}
httpResponseBuilder = new HttpResponseBuilder().setContent("keys3");
// Executor available again, accepting new Runnable instances
executorIsAcceptingRunnables = true;
instance.refreshInBackground();
waitForLatch(backgroundFetchFinishedLatch);
// Keys fetched in the background are used
assertEquals("keys3", instance.download());
// Only a single background fetch should have started
assertEquals(1, backgroundFetchStartedCount.get());
}
@Test
public void shouldFetchOnlyOnceWhenMultipleThreadsTryToGetKeys() throws Exception {
final KeysDownloader instance = newInstanceForTests();
httpResponseBuilder = new HttpResponseBuilder().setContent("keys");
List<FutureTask<String>> tasks = new ArrayList<>();
for (int i = 0; i < 10; i++) {
tasks.add(
new FutureTask<String>(
new Callable<String>() {
@Override
public String call() throws Exception {
return instance.download();
}
}));
}
// Force the HTTP responses to be delayed until the latch goes down to 0.
delayHttpResponseLatch = new CountDownLatch(1);
// Execute the all fetches in parallel.
for (FutureTask<String> task : tasks) {
executor.execute(task);
}
// Releasing the response.
delayHttpResponseLatch.countDown();
for (FutureTask<String> task : tasks) {
assertEquals("keys", task.get(5, TimeUnit.SECONDS));
}
// Should only have hit the network once.
assertEquals(1, httpTransportGetCount.get());
}
@Test
public void
shouldFetchOnlyOnceInBackgroundHalfWayThroughExpirationWhenMultipleThreadsTryToGetKeys()
throws Exception {
final KeysDownloader instance = newInstanceForTests();
httpResponseBuilder =
new HttpResponseBuilder().setContent("keys1").setCacheControlWithMaxAgeInSeconds(3L);
// Fetched and cached keys
assertEquals("keys1", instance.download());
// Keys changed
httpResponseBuilder = new HttpResponseBuilder().setContent("keys2");
// 1.5 seconds later
currentTimeInMillis += 1500L;
List<FutureTask<String>> tasks = new ArrayList<>();
for (int i = 0; i < 10; i++) {
tasks.add(
new FutureTask<String>(
new Callable<String>() {
@Override
public String call() throws Exception {
return instance.download();
}
}));
}
// Resetting counters
httpTransportGetCount.set(0);
backgroundFetchStartedCount.set(0);
// Force the HTTP responses to be delayed until the latch goes down to 0.
delayHttpResponseLatch = new CountDownLatch(1);
// Execute the all fetches in parallel.
for (FutureTask<String> task : tasks) {
executor.execute(task);
}
// Wait for all of them to complete (will use old keys that were cached)
for (FutureTask<String> task : tasks) {
assertEquals("keys1", task.get(5, TimeUnit.SECONDS));
}
// Releasing the response.
delayHttpResponseLatch.countDown();
// Waiting background fetch to finish
waitForLatch(backgroundFetchFinishedLatch);
// Only a single background fetch should have been triggered
assertEquals(1, backgroundFetchStartedCount.get());
// Should only have hit the network once.
assertEquals(1, httpTransportGetCount.get());
}
private static void waitForLatch(CountDownLatch latch) {
try {
assertTrue("Timed out!", latch.await(5, TimeUnit.SECONDS));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
private TestKeysDownloader newInstanceForTests() {
return new TestKeysDownloader(
new Executor() {
@Override
public void execute(final Runnable command) {
if (!executorIsAcceptingRunnables) {
throw new RejectedExecutionException();
}
executor.execute(
new Runnable() {
@Override
public void run() {
backgroundFetchStartedCount.incrementAndGet();
try {
command.run();
} finally {
backgroundFetchFinishedLatch.countDown();
}
}
});
}
},
new MockHttpTransport() {
@Override
public LowLevelHttpRequest buildRequest(String method, String url) throws IOException {
httpTransportGetCount.incrementAndGet();
if (delayHttpResponseLatch != null) {
waitForLatch(delayHttpResponseLatch);
}
assertEquals("https://someUrl", url);
assertEquals("GET", method);
MockLowLevelHttpRequest request = new MockLowLevelHttpRequest(url);
request.setResponse(httpResponseBuilder.build());
return request;
}
},
"https://someUrl");
}
private static class TestKeysDownloader extends KeysDownloader {
private static KeysDownloaderTest sTestInstance;
TestKeysDownloader(Executor backgroundExecutor, HttpTransport httpTransport, String keysUrl) {
super(backgroundExecutor, httpTransport, keysUrl);
}
@Override
long getCurrentTimeInMillis() {
return sTestInstance.currentTimeInMillis;
}
}
private static class HttpResponseBuilder {
private String content = "content";
private Long maxAgeInSeconds = 10L;
private Long ageInSeconds;
private int statusCode = HttpStatusCodes.STATUS_CODE_OK;
public HttpResponseBuilder setStatusCode(int statusCode) {
this.statusCode = statusCode;
return this;
}
public HttpResponseBuilder setContent(String content) {
this.content = content;
return this;
}
public HttpResponseBuilder setCacheControlWithMaxAgeInSeconds(Long maxAgeInSeconds) {
this.maxAgeInSeconds = maxAgeInSeconds;
return this;
}
public HttpResponseBuilder clearCacheControl() {
this.maxAgeInSeconds = null;
return this;
}
public HttpResponseBuilder setAgeInSeconds(Long ageInSeconds) {
this.ageInSeconds = ageInSeconds;
return this;
}
public MockLowLevelHttpResponse build() {
MockLowLevelHttpResponse response =
new MockLowLevelHttpResponse().setContent(content).setStatusCode(statusCode);
if (ageInSeconds != null) {
response.addHeader("Age", Long.toString(ageInSeconds));
}
if (maxAgeInSeconds != null) {
response.addHeader("Cache-Control", "public, max-age=" + maxAgeInSeconds);
}
return response;
}
}
}