1 // Copyright 2017 Google Inc. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 // 15 //////////////////////////////////////////////////////////////////////////////// 16 17 package com.google.crypto.tink.util; 18 19 import static org.junit.Assert.assertEquals; 20 import static org.junit.Assert.assertThrows; 21 import static org.junit.Assert.assertTrue; 22 23 import com.google.api.client.http.HttpStatusCodes; 24 import com.google.api.client.http.HttpTransport; 25 import com.google.api.client.http.LowLevelHttpRequest; 26 import com.google.api.client.testing.http.MockHttpTransport; 27 import com.google.api.client.testing.http.MockLowLevelHttpRequest; 28 import com.google.api.client.testing.http.MockLowLevelHttpResponse; 29 import com.google.errorprone.annotations.CanIgnoreReturnValue; 30 import java.io.IOException; 31 import java.util.ArrayList; 32 import java.util.List; 33 import java.util.concurrent.Callable; 34 import java.util.concurrent.CountDownLatch; 35 import java.util.concurrent.Executor; 36 import java.util.concurrent.ExecutorService; 37 import java.util.concurrent.Executors; 38 import java.util.concurrent.FutureTask; 39 import java.util.concurrent.RejectedExecutionException; 40 import java.util.concurrent.TimeUnit; 41 import java.util.concurrent.atomic.AtomicInteger; 42 import org.junit.After; 43 import org.junit.Before; 44 import org.junit.Test; 45 import org.junit.runner.RunWith; 46 import org.junit.runners.JUnit4; 47 48 /** Tests for {@link KeysDownloader}. */ 49 @RunWith(JUnit4.class) 50 public class KeysDownloaderTest { 51 private static final long INITIAL_CURRENT_TIME_IN_MILLIS = 1000; 52 53 private CountDownLatch backgroundFetchFinishedLatch; 54 private CountDownLatch delayHttpResponseLatch; 55 private ExecutorService executor; 56 private HttpResponseBuilder httpResponseBuilder; 57 private AtomicInteger backgroundFetchStartedCount; 58 private AtomicInteger httpTransportGetCount; 59 private boolean executorIsAcceptingRunnables; 60 private long currentTimeInMillis; 61 62 @Before setUp()63 public void setUp() { 64 backgroundFetchFinishedLatch = new CountDownLatch(1); 65 delayHttpResponseLatch = null; 66 executor = Executors.newCachedThreadPool(); 67 httpResponseBuilder = new HttpResponseBuilder(); 68 backgroundFetchStartedCount = new AtomicInteger(0); 69 httpTransportGetCount = new AtomicInteger(0); 70 currentTimeInMillis = INITIAL_CURRENT_TIME_IN_MILLIS; 71 executorIsAcceptingRunnables = true; 72 TestKeysDownloader.testInstance = this; 73 } 74 75 @After tearDown()76 public void tearDown() throws Exception { 77 executor.shutdownNow(); 78 assertTrue( 79 "Timed out while waiting for the threadpool to terminate!", 80 executor.awaitTermination(1, TimeUnit.SECONDS)); 81 } 82 83 @Test builderShouldThrowIllegalArgumentExceptionWhenUrlIsNotHttps()84 public void builderShouldThrowIllegalArgumentExceptionWhenUrlIsNotHttps() { 85 assertThrows( 86 IllegalArgumentException.class, 87 () -> new KeysDownloader.Builder().setUrl("http://abc").build()); 88 } 89 90 @Test shouldFetchKeys()91 public void shouldFetchKeys() throws Exception { 92 httpResponseBuilder = new HttpResponseBuilder().setContent("keys"); 93 94 assertEquals("keys", newInstanceForTests().download()); 95 } 96 97 @Test shouldThrowOnSuccessHttpResponsesThatAreNotOk()98 public void shouldThrowOnSuccessHttpResponsesThatAreNotOk() throws Exception { 99 httpResponseBuilder = 100 new HttpResponseBuilder().setStatusCode(HttpStatusCodes.STATUS_CODE_NO_CONTENT); 101 KeysDownloader instance = newInstanceForTests(); 102 103 IOException expected = assertThrows(IOException.class, instance::download); 104 assertEquals( 105 "Unexpected status code = " + HttpStatusCodes.STATUS_CODE_NO_CONTENT, 106 expected.getMessage()); 107 } 108 109 @Test shouldThrowOnNonSuccessHttpResponses()110 public void shouldThrowOnNonSuccessHttpResponses() throws Exception { 111 httpResponseBuilder = 112 new HttpResponseBuilder().setStatusCode(HttpStatusCodes.STATUS_CODE_NO_CONTENT); 113 KeysDownloader instance = newInstanceForTests(); 114 115 IOException expected = assertThrows(IOException.class, instance::download); 116 assertTrue( 117 "Message " 118 + expected.getMessage() 119 + " should contain " 120 + HttpStatusCodes.STATUS_CODE_NO_CONTENT, 121 expected.getMessage().contains(Integer.toString(HttpStatusCodes.STATUS_CODE_NO_CONTENT))); 122 } 123 124 @Test shouldCacheKeysOnFetches()125 public void shouldCacheKeysOnFetches() throws Exception { 126 KeysDownloader instance = newInstanceForTests(); 127 httpResponseBuilder = new HttpResponseBuilder().setContent("keys1"); 128 // Fetched and cached keys 129 assertEquals("keys1", instance.download()); 130 // Keys changed 131 httpResponseBuilder = new HttpResponseBuilder().setContent("keys2"); 132 133 // Old keys are returned 134 assertEquals("keys1", instance.download()); 135 } 136 137 @Test shouldFetchKeysAgainIfNoCacheControlHeadersAreSent()138 public void shouldFetchKeysAgainIfNoCacheControlHeadersAreSent() throws Exception { 139 KeysDownloader instance = newInstanceForTests(); 140 httpResponseBuilder = new HttpResponseBuilder().setContent("keys1").clearCacheControl(); 141 // Fetched and cached keys 142 assertEquals("keys1", instance.download()); 143 // Keys changed 144 httpResponseBuilder = new HttpResponseBuilder().setContent("keys2"); 145 146 // New keys are fetched and returned 147 assertEquals("keys2", instance.download()); 148 } 149 150 @Test shouldFetchKeysAgainAfterExpiration()151 public void shouldFetchKeysAgainAfterExpiration() throws Exception { 152 KeysDownloader instance = newInstanceForTests(); 153 httpResponseBuilder = 154 new HttpResponseBuilder().setContent("keys1").setCacheControlWithMaxAgeInSeconds(3L); 155 // Fetched and cached keys 156 assertEquals("keys1", instance.download()); 157 // Keys changed 158 httpResponseBuilder = new HttpResponseBuilder().setContent("keys2"); 159 // 3 seconds later ... 160 currentTimeInMillis += 3000L; 161 162 // New keys are fetched and returned 163 assertEquals("keys2", instance.download()); 164 } 165 166 @Test shouldReturnCachedKeysBeforeExpiration()167 public void shouldReturnCachedKeysBeforeExpiration() throws Exception { 168 KeysDownloader instance = newInstanceForTests(); 169 httpResponseBuilder = 170 new HttpResponseBuilder().setContent("keys1").setCacheControlWithMaxAgeInSeconds(3L); 171 // Fetched and cached keys 172 assertEquals("keys1", instance.download()); 173 // Keys changed 174 httpResponseBuilder = new HttpResponseBuilder().setContent("keys2"); 175 // 3 seconds - 1ms later ... 176 currentTimeInMillis += 3000L - 1; 177 178 // Old keys are sill returned 179 assertEquals("keys1", instance.download()); 180 } 181 182 @Test shouldFetchKeysAgainAfterExpirationAccountingForAgeHeader()183 public void shouldFetchKeysAgainAfterExpirationAccountingForAgeHeader() throws Exception { 184 KeysDownloader instance = newInstanceForTests(); 185 httpResponseBuilder = 186 new HttpResponseBuilder() 187 .setContent("keys1") 188 .setCacheControlWithMaxAgeInSeconds(3L) 189 .setAgeInSeconds(1L); 190 // Fetched and cached keys 191 assertEquals("keys1", instance.download()); 192 // Keys changed 193 httpResponseBuilder = new HttpResponseBuilder().setContent("keys2"); 194 // 2 seconds later ... 195 currentTimeInMillis += 2000L; 196 197 // New keys are fetched and returned 198 assertEquals("keys2", instance.download()); 199 } 200 201 @Test shouldReturnCachedKeysBeforeExpirationAccountingForAgeHeader()202 public void shouldReturnCachedKeysBeforeExpirationAccountingForAgeHeader() throws Exception { 203 KeysDownloader instance = newInstanceForTests(); 204 httpResponseBuilder = 205 new HttpResponseBuilder() 206 .setContent("keys1") 207 .setCacheControlWithMaxAgeInSeconds(3L) 208 .setAgeInSeconds(1L); 209 // Fetched and cached keys 210 assertEquals("keys1", instance.download()); 211 // Keys changed 212 httpResponseBuilder = new HttpResponseBuilder().setContent("keys2"); 213 // 2 seconds - 1ms later ... 214 currentTimeInMillis += 2000L - 1; 215 216 // Old keys are sill returned 217 assertEquals("keys1", instance.download()); 218 } 219 220 @Test shouldTriggerBackgroundRefreshHalfWayThroughExpiration()221 public void shouldTriggerBackgroundRefreshHalfWayThroughExpiration() throws Exception { 222 KeysDownloader instance = newInstanceForTests(); 223 httpResponseBuilder = 224 new HttpResponseBuilder().setContent("keys1").setCacheControlWithMaxAgeInSeconds(3L); 225 // Fetched and cached keys 226 assertEquals("keys1", instance.download()); 227 // Keys changed 228 httpResponseBuilder = new HttpResponseBuilder().setContent("keys2"); 229 // 1.5 seconds later ... 230 currentTimeInMillis += 1500L; 231 // Old keys are sill returned, but a background fetch is initiated 232 assertEquals("keys1", instance.download()); 233 // Wait background fetch to complete 234 waitForLatch(backgroundFetchFinishedLatch); 235 // 10ms later ... 236 currentTimeInMillis += 10; 237 // Keys changed again 238 httpResponseBuilder = new HttpResponseBuilder().setContent("keys3"); 239 240 // Keys fetched in the background are used 241 assertEquals("keys2", instance.download()); 242 // Single background fetch should have been triggered 243 assertEquals(1, backgroundFetchStartedCount.get()); 244 } 245 246 @Test shouldNotTriggerBackgroundRefreshBeforeHalfWayThroughExpiration()247 public void shouldNotTriggerBackgroundRefreshBeforeHalfWayThroughExpiration() throws Exception { 248 KeysDownloader instance = newInstanceForTests(); 249 httpResponseBuilder = 250 new HttpResponseBuilder().setContent("keys1").setCacheControlWithMaxAgeInSeconds(3L); 251 // Fetched and cached keys 252 assertEquals("keys1", instance.download()); 253 // Keys changed 254 httpResponseBuilder = new HttpResponseBuilder().setContent("keys2"); 255 // 1.5 seconds - 1ms later ... 256 currentTimeInMillis += 1500L - 1; 257 258 // Old keys are sill returned 259 assertEquals("keys1", instance.download()); 260 // No background fetch should have been triggered 261 assertEquals(0, backgroundFetchStartedCount.get()); 262 } 263 264 @Test shouldPerformBackgroundRefreshWhenRequestedAndHaveCacheKeys()265 public void shouldPerformBackgroundRefreshWhenRequestedAndHaveCacheKeys() throws Exception { 266 KeysDownloader instance = newInstanceForTests(); 267 httpResponseBuilder = 268 new HttpResponseBuilder().setContent("keys1").setCacheControlWithMaxAgeInSeconds(3L); 269 // Fetched and cache keys 270 instance.refreshInBackground(); 271 // Wait background fetch to complete 272 waitForLatch(backgroundFetchFinishedLatch); 273 // Keys changed 274 httpResponseBuilder = new HttpResponseBuilder().setContent("keys2"); 275 276 // Keys fetched in the background are used 277 assertEquals("keys1", instance.download()); 278 // Single background fetch should have been triggered 279 assertEquals(1, backgroundFetchStartedCount.get()); 280 // Single http fetch should have been triggered 281 assertEquals(1, httpTransportGetCount.get()); 282 } 283 284 @Test shouldPerformMultipleRefreshesWhenRequested()285 public void shouldPerformMultipleRefreshesWhenRequested() throws Exception { 286 KeysDownloader instance = newInstanceForTests(); 287 httpResponseBuilder = new HttpResponseBuilder().setContent("keys1"); 288 instance.refreshInBackground(); 289 waitForLatch(backgroundFetchFinishedLatch); 290 httpResponseBuilder = new HttpResponseBuilder().setContent("keys2"); 291 backgroundFetchFinishedLatch = new CountDownLatch(1); 292 instance.refreshInBackground(); 293 waitForLatch(backgroundFetchFinishedLatch); 294 295 // Keys fetched in the background are used 296 assertEquals("keys2", instance.download()); 297 // Multiple background fetch should have been triggered 298 assertEquals(2, backgroundFetchStartedCount.get()); 299 // Multiple http fetch should have been triggered 300 assertEquals(2, httpTransportGetCount.get()); 301 } 302 303 @Test shouldPerformRefreshAfterExecutorTransientFailure()304 public void shouldPerformRefreshAfterExecutorTransientFailure() throws Exception { 305 KeysDownloader instance = newInstanceForTests(); 306 httpResponseBuilder = new HttpResponseBuilder().setContent("keys1"); 307 Object unused = instance.download(); 308 httpResponseBuilder = new HttpResponseBuilder().setContent("keys2"); 309 // Executor temporarily full, rejecting new Runnable instances 310 executorIsAcceptingRunnables = false; 311 assertThrows(RejectedExecutionException.class, instance::refreshInBackground); 312 httpResponseBuilder = new HttpResponseBuilder().setContent("keys3"); 313 // Executor available again, accepting new Runnable instances 314 executorIsAcceptingRunnables = true; 315 instance.refreshInBackground(); 316 waitForLatch(backgroundFetchFinishedLatch); 317 318 // Keys fetched in the background are used 319 assertEquals("keys3", instance.download()); 320 // Only a single background fetch should have started 321 assertEquals(1, backgroundFetchStartedCount.get()); 322 } 323 324 @Test shouldFetchOnlyOnceWhenMultipleThreadsTryToGetKeys()325 public void shouldFetchOnlyOnceWhenMultipleThreadsTryToGetKeys() throws Exception { 326 final KeysDownloader instance = newInstanceForTests(); 327 httpResponseBuilder = new HttpResponseBuilder().setContent("keys"); 328 List<FutureTask<String>> tasks = new ArrayList<>(); 329 for (int i = 0; i < 10; i++) { 330 tasks.add( 331 new FutureTask<String>( 332 new Callable<String>() { 333 @Override 334 public String call() throws Exception { 335 return instance.download(); 336 } 337 })); 338 } 339 340 // Force the HTTP responses to be delayed until the latch goes down to 0. 341 delayHttpResponseLatch = new CountDownLatch(1); 342 // Execute the all fetches in parallel. 343 for (FutureTask<String> task : tasks) { 344 executor.execute(task); 345 } 346 // Releasing the response. 347 delayHttpResponseLatch.countDown(); 348 349 for (FutureTask<String> task : tasks) { 350 assertEquals("keys", task.get(5, TimeUnit.SECONDS)); 351 } 352 // Should only have hit the network once. 353 assertEquals(1, httpTransportGetCount.get()); 354 } 355 356 @Test 357 public void shouldFetchOnlyOnceInBackgroundHalfWayThroughExpirationWhenMultipleThreadsTryToGetKeys()358 shouldFetchOnlyOnceInBackgroundHalfWayThroughExpirationWhenMultipleThreadsTryToGetKeys() 359 throws Exception { 360 final KeysDownloader instance = newInstanceForTests(); 361 httpResponseBuilder = 362 new HttpResponseBuilder().setContent("keys1").setCacheControlWithMaxAgeInSeconds(3L); 363 // Fetched and cached keys 364 assertEquals("keys1", instance.download()); 365 // Keys changed 366 httpResponseBuilder = new HttpResponseBuilder().setContent("keys2"); 367 // 1.5 seconds later 368 currentTimeInMillis += 1500L; 369 List<FutureTask<String>> tasks = new ArrayList<>(); 370 for (int i = 0; i < 10; i++) { 371 tasks.add( 372 new FutureTask<String>( 373 new Callable<String>() { 374 @Override 375 public String call() throws Exception { 376 return instance.download(); 377 } 378 })); 379 } 380 // Resetting counters 381 httpTransportGetCount.set(0); 382 backgroundFetchStartedCount.set(0); 383 384 // Force the HTTP responses to be delayed until the latch goes down to 0. 385 delayHttpResponseLatch = new CountDownLatch(1); 386 // Execute the all fetches in parallel. 387 for (FutureTask<String> task : tasks) { 388 executor.execute(task); 389 } 390 // Wait for all of them to complete (will use old keys that were cached) 391 for (FutureTask<String> task : tasks) { 392 assertEquals("keys1", task.get(5, TimeUnit.SECONDS)); 393 } 394 // Releasing the response. 395 delayHttpResponseLatch.countDown(); 396 // Waiting background fetch to finish 397 waitForLatch(backgroundFetchFinishedLatch); 398 399 // Only a single background fetch should have been triggered 400 assertEquals(1, backgroundFetchStartedCount.get()); 401 // Should only have hit the network once. 402 assertEquals(1, httpTransportGetCount.get()); 403 } 404 waitForLatch(CountDownLatch latch)405 private static void waitForLatch(CountDownLatch latch) { 406 try { 407 assertTrue("Timed out!", latch.await(5, TimeUnit.SECONDS)); 408 } catch (InterruptedException e) { 409 throw new RuntimeException(e); 410 } 411 } 412 newInstanceForTests()413 private TestKeysDownloader newInstanceForTests() { 414 return new TestKeysDownloader( 415 new Executor() { 416 @Override 417 public void execute(final Runnable command) { 418 if (!executorIsAcceptingRunnables) { 419 throw new RejectedExecutionException(); 420 } 421 executor.execute( 422 new Runnable() { 423 @Override 424 public void run() { 425 backgroundFetchStartedCount.incrementAndGet(); 426 try { 427 command.run(); 428 } finally { 429 backgroundFetchFinishedLatch.countDown(); 430 } 431 } 432 }); 433 } 434 }, 435 new MockHttpTransport() { 436 @Override 437 public LowLevelHttpRequest buildRequest(String method, String url) throws IOException { 438 httpTransportGetCount.incrementAndGet(); 439 if (delayHttpResponseLatch != null) { 440 waitForLatch(delayHttpResponseLatch); 441 } 442 assertEquals("https://someUrl", url); 443 assertEquals("GET", method); 444 MockLowLevelHttpRequest request = new MockLowLevelHttpRequest(url); 445 request.setResponse(httpResponseBuilder.build()); 446 return request; 447 } 448 }, 449 "https://someUrl"); 450 } 451 452 private static class TestKeysDownloader extends KeysDownloader { 453 private static KeysDownloaderTest testInstance; 454 455 TestKeysDownloader(Executor backgroundExecutor, HttpTransport httpTransport, String keysUrl) { 456 super(backgroundExecutor, httpTransport, keysUrl); 457 } 458 459 @Override 460 long getCurrentTimeInMillis() { 461 return testInstance.currentTimeInMillis; 462 } 463 } 464 465 private static class HttpResponseBuilder { 466 private String content = "content"; 467 private Long maxAgeInSeconds = 10L; 468 private Long ageInSeconds; 469 private int statusCode = HttpStatusCodes.STATUS_CODE_OK; 470 471 @CanIgnoreReturnValue 472 public HttpResponseBuilder setStatusCode(int statusCode) { 473 this.statusCode = statusCode; 474 return this; 475 } 476 477 @CanIgnoreReturnValue 478 public HttpResponseBuilder setContent(String content) { 479 this.content = content; 480 return this; 481 } 482 483 @CanIgnoreReturnValue 484 public HttpResponseBuilder setCacheControlWithMaxAgeInSeconds(Long maxAgeInSeconds) { 485 this.maxAgeInSeconds = maxAgeInSeconds; 486 return this; 487 } 488 489 @CanIgnoreReturnValue 490 public HttpResponseBuilder clearCacheControl() { 491 this.maxAgeInSeconds = null; 492 return this; 493 } 494 495 @CanIgnoreReturnValue 496 public HttpResponseBuilder setAgeInSeconds(Long ageInSeconds) { 497 this.ageInSeconds = ageInSeconds; 498 return this; 499 } 500 501 public MockLowLevelHttpResponse build() { 502 MockLowLevelHttpResponse response = 503 new MockLowLevelHttpResponse().setContent(content).setStatusCode(statusCode); 504 if (ageInSeconds != null) { 505 response.addHeader("Age", Long.toString(ageInSeconds)); 506 } 507 if (maxAgeInSeconds != null) { 508 response.addHeader("Cache-Control", "public, max-age=" + maxAgeInSeconds); 509 } 510 return response; 511 } 512 } 513 } 514