001/* 002 * acme4j - Java ACME client 003 * 004 * Copyright (C) 2015 Richard "Shred" Körber 005 * http://acme4j.shredzone.org 006 * 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 013 */ 014package org.shredzone.acme4j.connector; 015 016import static java.nio.charset.StandardCharsets.UTF_8; 017import static java.time.temporal.ChronoUnit.SECONDS; 018import static com.github.tomakehurst.wiremock.client.WireMock.*; 019import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; 020import static org.assertj.core.api.Assertions.assertThat; 021import static org.assertj.core.api.Assertions.assertThatExceptionOfType; 022import static org.junit.jupiter.api.Assertions.assertThrows; 023import static org.shredzone.acme4j.toolbox.TestUtils.getResourceAsByteArray; 024import static org.shredzone.acme4j.toolbox.TestUtils.url; 025 026import java.io.ByteArrayOutputStream; 027import java.io.OutputStreamWriter; 028import java.net.HttpURLConnection; 029import java.net.URI; 030import java.net.URL; 031import java.security.KeyPair; 032import java.security.cert.X509Certificate; 033import java.time.Duration; 034import java.time.Instant; 035import java.time.ZoneId; 036import java.time.ZoneOffset; 037import java.time.ZonedDateTime; 038import java.time.format.DateTimeFormatter; 039import java.util.Arrays; 040import java.util.Base64; 041import java.util.List; 042import java.util.Locale; 043 044import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; 045import com.github.tomakehurst.wiremock.junit5.WireMockTest; 046import org.jose4j.jws.JsonWebSignature; 047import org.jose4j.jwx.CompactSerializer; 048import org.junit.jupiter.api.BeforeEach; 049import org.junit.jupiter.api.Test; 050import org.shredzone.acme4j.Login; 051import org.shredzone.acme4j.Session; 052import org.shredzone.acme4j.exception.AcmeException; 053import org.shredzone.acme4j.exception.AcmeProtocolException; 054import org.shredzone.acme4j.exception.AcmeRateLimitedException; 055import org.shredzone.acme4j.exception.AcmeRetryAfterException; 056import org.shredzone.acme4j.exception.AcmeServerException; 057import org.shredzone.acme4j.exception.AcmeUnauthorizedException; 058import org.shredzone.acme4j.exception.AcmeUserActionRequiredException; 059import org.shredzone.acme4j.toolbox.AcmeUtils; 060import org.shredzone.acme4j.toolbox.JSON; 061import org.shredzone.acme4j.toolbox.JSONBuilder; 062import org.shredzone.acme4j.toolbox.TestUtils; 063 064/** 065 * Unit tests for {@link DefaultConnection}. 066 */ 067@WireMockTest 068public class DefaultConnectionTest { 069 070 private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); 071 private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder(); 072 private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC); 073 private static final String DIRECTORY_PATH = "/dir"; 074 private static final String NEW_NONCE_PATH = "/newNonce"; 075 private static final String REQUEST_PATH = "/test/test"; 076 private static final String TEST_ACCEPT_LANGUAGE = "ja-JP,ja;q=0.8,*;q=0.1"; 077 private static final String TEST_ACCEPT_CHARSET = "utf-8"; 078 private static final String TEST_USER_AGENT_PATTERN = "^acme4j/.*$"; 079 080 private final URL accountUrl = TestUtils.url(TestUtils.ACCOUNT_URL); 081 private Session session; 082 private Login login; 083 private KeyPair keyPair; 084 private String baseUrl; 085 private URL directoryUrl; 086 private URL newNonceUrl; 087 private URL requestUrl; 088 089 @BeforeEach 090 public void setup(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { 091 baseUrl = wmRuntimeInfo.getHttpBaseUrl(); 092 directoryUrl = new URL(baseUrl + DIRECTORY_PATH); 093 newNonceUrl = new URL(baseUrl + NEW_NONCE_PATH); 094 requestUrl = new URL(baseUrl + REQUEST_PATH); 095 096 session = new Session(directoryUrl.toURI()); 097 session.setLocale(Locale.JAPAN); 098 099 keyPair = TestUtils.createKeyPair(); 100 101 login = session.login(accountUrl, keyPair); 102 103 var directory = new JSONBuilder(); 104 directory.put("newNonce", newNonceUrl); 105 106 stubFor(get(DIRECTORY_PATH).willReturn(okJson(directory.toString()))); 107 } 108 109 /** 110 * Test that {@link DefaultConnection#getNonce()} is empty if there is no 111 * {@code Replay-Nonce} header. 112 */ 113 @Test 114 public void testNoNonceFromHeader() throws AcmeException { 115 stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok())); 116 117 assertThat(session.getNonce()).isNull(); 118 119 try (var conn = session.connect()) { 120 conn.sendRequest(directoryUrl, session, null); 121 assertThat(conn.getNonce()).isEmpty(); 122 } 123 } 124 125 /** 126 * Test that {@link DefaultConnection#getNonce()} extracts a {@code Replay-Nonce} 127 * header correctly. 128 */ 129 @Test 130 public void testGetNonceFromHeader() throws AcmeException { 131 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 132 .withHeader("Replay-Nonce", TestUtils.DUMMY_NONCE) 133 )); 134 135 assertThat(session.getNonce()).isNull(); 136 137 try (var conn = session.connect()) { 138 conn.sendRequest(requestUrl, session, null); 139 assertThat(conn.getNonce().orElseThrow()).isEqualTo(TestUtils.DUMMY_NONCE); 140 assertThat(session.getNonce()).isEqualTo(TestUtils.DUMMY_NONCE); 141 } 142 143 verify(getRequestedFor(urlEqualTo(REQUEST_PATH))); 144 } 145 146 /** 147 * Test that {@link DefaultConnection#getNonce()} handles a retry-after header 148 * correctly. 149 */ 150 @Test 151 public void testGetNonceFromHeaderRetryAfter() { 152 var retryAfter = Instant.now().plusSeconds(30L).truncatedTo(SECONDS); 153 154 stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(aResponse() 155 .withStatus(HttpURLConnection.HTTP_UNAVAILABLE) 156 .withHeader("Content-Type", "application/problem+json") 157 .withHeader("Retry-After", DATE_FORMATTER.format(retryAfter)) 158 // do not send a body here because it is a HEAD request! 159 )); 160 161 assertThat(session.getNonce()).isNull(); 162 163 var ex = assertThrows(AcmeRetryAfterException.class, () -> { 164 try (var conn = session.connect()) { 165 conn.resetNonce(session); 166 } 167 }); 168 assertThat(ex.getMessage()).isEqualTo("Server responded with HTTP 503 while trying to retrieve a nonce"); 169 assertThat(ex.getRetryAfter()).isEqualTo(retryAfter); 170 171 verify(headRequestedFor(urlEqualTo(NEW_NONCE_PATH))); 172 } 173 174 /** 175 * Test that {@link DefaultConnection#getNonce()} handles a general HTTP error 176 * correctly. 177 */ 178 @Test 179 public void testGetNonceFromHeaderHttpError() { 180 stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(aResponse() 181 .withStatus(HttpURLConnection.HTTP_INTERNAL_ERROR) 182 // do not send a body here because it is a HEAD request! 183 )); 184 185 assertThat(session.getNonce()).isNull(); 186 187 var ex = assertThrows(AcmeException.class, () -> { 188 try (var conn = session.connect()) { 189 conn.resetNonce(session); 190 } 191 }); 192 assertThat(ex.getMessage()).isEqualTo("Server responded with HTTP 500 while trying to retrieve a nonce"); 193 194 verify(headRequestedFor(urlEqualTo(NEW_NONCE_PATH))); 195 } 196 197 /** 198 * Test that {@link DefaultConnection#getNonce()} fails on an invalid 199 * {@code Replay-Nonce} header. 200 */ 201 @Test 202 public void testInvalidNonceFromHeader() { 203 var badNonce = "#$%&/*+*#'"; 204 205 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 206 .withHeader("Replay-Nonce", badNonce) 207 )); 208 209 var ex = assertThrows(AcmeProtocolException.class, () -> { 210 try (var conn = session.connect()) { 211 conn.sendRequest(requestUrl, session, null); 212 conn.getNonce(); 213 } 214 }); 215 assertThat(ex.getMessage()).startsWith("Invalid replay nonce"); 216 217 verify(getRequestedFor(urlEqualTo(REQUEST_PATH))); 218 } 219 220 /** 221 * Test that {@link DefaultConnection#resetNonce(Session)} fetches a new nonce via 222 * new-nonce resource and a HEAD request. 223 */ 224 @Test 225 public void testResetNonceSucceedsIfNoncePresent() throws AcmeException { 226 stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok() 227 .withHeader("Replay-Nonce", TestUtils.DUMMY_NONCE) 228 )); 229 230 assertThat(session.getNonce()).isNull(); 231 232 try (var conn = session.connect()) { 233 conn.resetNonce(session); 234 } 235 236 assertThat(session.getNonce()).isEqualTo(TestUtils.DUMMY_NONCE); 237 } 238 239 /** 240 * Test that {@link DefaultConnection#resetNonce(Session)} throws an exception if 241 * there is no nonce header. 242 */ 243 @Test 244 public void testResetNonceThrowsException() { 245 stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok())); 246 247 assertThat(session.getNonce()).isNull(); 248 249 assertThrows(AcmeProtocolException.class, () -> { 250 try (var conn = session.connect()) { 251 conn.resetNonce(session); 252 } 253 }); 254 255 assertThat(session.getNonce()).isNull(); 256 } 257 258 /** 259 * Test that an absolute Location header is evaluated. 260 */ 261 @Test 262 public void testGetAbsoluteLocation() throws Exception { 263 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 264 .withHeader("Location", "https://example.com/otherlocation") 265 )); 266 267 try (var conn = session.connect()) { 268 conn.sendRequest(requestUrl, session, null); 269 var location = conn.getLocation(); 270 assertThat(location).isEqualTo(new URL("https://example.com/otherlocation")); 271 } 272 } 273 274 /** 275 * Test that a relative Location header is evaluated. 276 */ 277 @Test 278 public void testGetRelativeLocation() throws Exception { 279 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 280 .withHeader("Location", "/otherlocation") 281 )); 282 283 try (var conn = session.connect()) { 284 conn.sendRequest(requestUrl, session, null); 285 var location = conn.getLocation(); 286 assertThat(location).isEqualTo(new URL(baseUrl + "/otherlocation")); 287 } 288 } 289 290 /** 291 * Test that absolute and relative Link headers are evaluated. 292 */ 293 @Test 294 public void testGetLink() throws Exception { 295 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 296 .withHeader("Link", "<https://example.com/acme/new-authz>;rel=\"next\"") 297 .withHeader("Link", "</recover-acct>;rel=recover") 298 .withHeader("Link", "<https://example.com/acme/terms>; rel=\"terms-of-service\"") 299 )); 300 301 try (var conn = session.connect()) { 302 conn.sendRequest(requestUrl, session, null); 303 assertThat(conn.getLinks("next")).containsExactly(new URL("https://example.com/acme/new-authz")); 304 assertThat(conn.getLinks("recover")).containsExactly(new URL(baseUrl + "/recover-acct")); 305 assertThat(conn.getLinks("terms-of-service")).containsExactly(new URL("https://example.com/acme/terms")); 306 assertThat(conn.getLinks("secret-stuff")).isEmpty(); 307 } 308 } 309 310 /** 311 * Test that multiple link headers are evaluated. 312 */ 313 @Test 314 public void testGetMultiLink() throws AcmeException { 315 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 316 .withHeader("Link", "<https://example.com/acme/terms1>; rel=\"terms-of-service\"") 317 .withHeader("Link", "<https://example.com/acme/terms2>; rel=\"terms-of-service\"") 318 .withHeader("Link", "<../terms3>; rel=\"terms-of-service\"") 319 )); 320 321 try (var conn = session.connect()) { 322 conn.sendRequest(requestUrl, session, null); 323 assertThat(conn.getLinks("terms-of-service")).containsExactlyInAnyOrder( 324 url("https://example.com/acme/terms1"), 325 url("https://example.com/acme/terms2"), 326 url(baseUrl + "/terms3") 327 ); 328 } 329 } 330 331 /** 332 * Test that no link headers are properly handled. 333 */ 334 @Test 335 public void testGetNoLink() throws AcmeException { 336 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok())); 337 338 try (var conn = session.connect()) { 339 conn.sendRequest(requestUrl, session, null); 340 assertThat(conn.getLinks("something")).isEmpty(); 341 } 342 } 343 344 /** 345 * Test that no Location header returns {@code null}. 346 */ 347 @Test 348 public void testNoLocation() throws AcmeException { 349 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok())); 350 351 try (var conn = session.connect()) { 352 conn.sendRequest(requestUrl, session, null); 353 assertThatExceptionOfType(AcmeProtocolException.class) 354 .isThrownBy(conn::getLocation); 355 } 356 357 verify(getRequestedFor(urlEqualTo(REQUEST_PATH))); 358 } 359 360 /** 361 * Test if Retry-After header with absolute date is correctly parsed. 362 */ 363 @Test 364 public void testHandleRetryAfterHeaderDate() throws AcmeException { 365 var retryDate = Instant.now().plus(Duration.ofHours(10)).truncatedTo(SECONDS); 366 367 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 368 .withHeader("Retry-After", DATE_FORMATTER.format(retryDate)) 369 )); 370 371 try (var conn = session.connect()) { 372 conn.sendRequest(requestUrl, session, null); 373 assertThat(conn.getRetryAfter()).hasValue(retryDate); 374 } 375 } 376 377 /** 378 * Test if Retry-After header with relative timespan is correctly parsed. 379 */ 380 @Test 381 public void testHandleRetryAfterHeaderDelta() throws AcmeException { 382 var delta = 10 * 60 * 60; 383 var now = Instant.now().truncatedTo(SECONDS); 384 var retryMsg = "relative time"; 385 386 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 387 .withHeader("Retry-After", String.valueOf(delta)) 388 .withHeader("Date", DATE_FORMATTER.format(now)) 389 )); 390 391 try (var conn = session.connect()) { 392 conn.sendRequest(requestUrl, session, null); 393 assertThat(conn.getRetryAfter()).hasValue(now.plusSeconds(delta)); 394 } 395 } 396 397 /** 398 * Test if no Retry-After header is correctly handled. 399 */ 400 @Test 401 public void testHandleRetryAfterHeaderNull() throws AcmeException { 402 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 403 .withHeader("Date", DATE_FORMATTER.format(Instant.now())) 404 )); 405 406 try (var conn = session.connect()) { 407 conn.sendRequest(requestUrl, session, null); 408 assertThat(conn.getRetryAfter()).isEmpty(); 409 } 410 411 verify(getRequestedFor(urlEqualTo(REQUEST_PATH))); 412 } 413 414 /** 415 * Test if no exception is thrown on a standard request. 416 */ 417 @Test 418 public void testAccept() throws AcmeException { 419 stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(ok() 420 .withBody("") 421 )); 422 423 session.setNonce(TestUtils.DUMMY_NONCE); 424 425 try (var conn = session.connect()) { 426 var rc = conn.sendSignedRequest(requestUrl, new JSONBuilder(), login); 427 assertThat(rc).isEqualTo(HttpURLConnection.HTTP_OK); 428 } 429 430 verify(postRequestedFor(urlEqualTo(REQUEST_PATH))); 431 } 432 433 /** 434 * Test if an {@link AcmeServerException} is thrown on an acme problem. 435 */ 436 @Test 437 public void testAcceptThrowsException() { 438 var problem = new JSONBuilder(); 439 problem.put("type", "urn:ietf:params:acme:error:unauthorized"); 440 problem.put("detail", "Invalid response: 404"); 441 442 stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(aResponse() 443 .withStatus(HttpURLConnection.HTTP_FORBIDDEN) 444 .withHeader("Content-Type", "application/problem+json") 445 .withBody(problem.toString()) 446 )); 447 448 session.setNonce(TestUtils.DUMMY_NONCE); 449 450 var ex = assertThrows(AcmeException.class, () -> { 451 try (var conn = session.connect()) { 452 conn.sendSignedRequest(requestUrl, new JSONBuilder(), login); 453 } 454 }); 455 456 assertThat(ex).isInstanceOf(AcmeUnauthorizedException.class); 457 assertThat(((AcmeUnauthorizedException) ex).getType()) 458 .isEqualTo(URI.create("urn:ietf:params:acme:error:unauthorized")); 459 assertThat(ex.getMessage()).isEqualTo("Invalid response: 404"); 460 } 461 462 /** 463 * Test if an {@link AcmeUserActionRequiredException} is thrown on an acme problem. 464 */ 465 @Test 466 public void testAcceptThrowsUserActionRequiredException() { 467 var problem = new JSONBuilder(); 468 problem.put("type", "urn:ietf:params:acme:error:userActionRequired"); 469 problem.put("detail", "Accept the TOS"); 470 471 stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(aResponse() 472 .withStatus(HttpURLConnection.HTTP_FORBIDDEN) 473 .withHeader("Content-Type", "application/problem+json") 474 .withHeader("Link", "<https://example.com/tos.pdf>; rel=\"terms-of-service\"") 475 .withBody(problem.toString()) 476 )); 477 478 session.setNonce(TestUtils.DUMMY_NONCE); 479 480 var ex = assertThrows(AcmeException.class, () -> { 481 try (var conn = session.connect()) { 482 conn.sendSignedRequest(requestUrl, new JSONBuilder(), login); 483 } 484 }); 485 486 assertThat(ex).isInstanceOf(AcmeUserActionRequiredException.class); 487 assertThat(((AcmeUserActionRequiredException) ex).getType()) 488 .isEqualTo(URI.create("urn:ietf:params:acme:error:userActionRequired")); 489 assertThat(ex.getMessage()).isEqualTo("Accept the TOS"); 490 assertThat(((AcmeUserActionRequiredException) ex).getTermsOfServiceUri().orElseThrow()) 491 .isEqualTo(URI.create("https://example.com/tos.pdf")); 492 } 493 494 /** 495 * Test if an {@link AcmeRateLimitedException} is thrown on an acme problem. 496 */ 497 @Test 498 public void testAcceptThrowsRateLimitedException() { 499 var problem = new JSONBuilder(); 500 problem.put("type", "urn:ietf:params:acme:error:rateLimited"); 501 problem.put("detail", "Too many invocations"); 502 503 var retryAfter = Instant.now().plusSeconds(30L).truncatedTo(SECONDS); 504 505 stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(aResponse() 506 .withStatus(HttpURLConnection.HTTP_FORBIDDEN) 507 .withHeader("Content-Type", "application/problem+json") 508 .withHeader("Link", "<https://example.com/rates.pdf>; rel=\"help\"") 509 .withHeader("Retry-After", DATE_FORMATTER.format(retryAfter)) 510 .withBody(problem.toString()) 511 )); 512 513 session.setNonce(TestUtils.DUMMY_NONCE); 514 515 var ex = assertThrows(AcmeRateLimitedException.class, () -> { 516 try (var conn = session.connect()) { 517 conn.sendSignedRequest(requestUrl, new JSONBuilder(), login); 518 } 519 }); 520 521 assertThat(ex.getType()).isEqualTo(URI.create("urn:ietf:params:acme:error:rateLimited")); 522 assertThat(ex.getMessage()).isEqualTo("Too many invocations"); 523 assertThat(ex.getRetryAfter().orElseThrow()).isEqualTo(retryAfter); 524 assertThat(ex.getDocuments()).isNotNull(); 525 assertThat(ex.getDocuments()).hasSize(1); 526 assertThat(ex.getDocuments().iterator().next()).isEqualTo(url("https://example.com/rates.pdf")); 527 } 528 529 /** 530 * Test if an {@link AcmeServerException} is thrown on another problem. 531 */ 532 @Test 533 public void testAcceptThrowsOtherException() { 534 var problem = new JSONBuilder(); 535 problem.put("type", "urn:zombie:error:apocalypse"); 536 problem.put("detail", "Zombie apocalypse in progress"); 537 538 stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(aResponse() 539 .withStatus(HttpURLConnection.HTTP_INTERNAL_ERROR) 540 .withHeader("Content-Type", "application/problem+json") 541 .withBody(problem.toString()) 542 )); 543 544 session.setNonce(TestUtils.DUMMY_NONCE); 545 546 var ex = assertThrows(AcmeServerException.class, () -> { 547 try (var conn = session.connect()) { 548 conn.sendSignedRequest(requestUrl, new JSONBuilder(), login); 549 } 550 }); 551 552 assertThat(ex.getType()).isEqualTo(URI.create("urn:zombie:error:apocalypse")); 553 assertThat(ex.getMessage()).isEqualTo("Zombie apocalypse in progress"); 554 } 555 556 /** 557 * Test if an {@link AcmeException} is thrown if there is no error type. 558 */ 559 @Test 560 public void testAcceptThrowsNoTypeException() { 561 stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(aResponse() 562 .withStatus(HttpURLConnection.HTTP_INTERNAL_ERROR) 563 .withHeader("Content-Type", "application/problem+json") 564 .withBody("{}") 565 )); 566 567 session.setNonce(TestUtils.DUMMY_NONCE); 568 569 var ex = assertThrows(AcmeProtocolException.class, () -> { 570 try (var conn = session.connect()) { 571 conn.sendSignedRequest(requestUrl, new JSONBuilder(), login); 572 } 573 }); 574 assertThat(ex.getMessage()).isNotEmpty(); 575 } 576 577 /** 578 * Test if an {@link AcmeException} is thrown if there is a generic error. 579 */ 580 @Test 581 public void testAcceptThrowsServerException() { 582 stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(aResponse() 583 .withStatus(HttpURLConnection.HTTP_INTERNAL_ERROR) 584 .withStatusMessage("Infernal Server Error") 585 .withHeader("Content-Type", "text/html") 586 .withBody("<html><head><title>Infernal Server Error</title></head></html>") 587 )); 588 589 session.setNonce(TestUtils.DUMMY_NONCE); 590 591 var ex = assertThrows(AcmeException.class, () -> { 592 try (var conn = session.connect()) { 593 conn.sendSignedRequest(requestUrl, new JSONBuilder(), login); 594 } 595 }); 596 assertThat(ex.getMessage()).isEqualTo("HTTP 500"); 597 } 598 599 /** 600 * Test GET requests. 601 */ 602 @Test 603 public void testSendRequest() throws AcmeException { 604 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok())); 605 606 try (var conn = session.connect()) { 607 conn.sendRequest(requestUrl, session, null); 608 } 609 610 verify(getRequestedFor(urlEqualTo(REQUEST_PATH)) 611 .withHeader("Accept", equalTo("application/json")) 612 .withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET)) 613 .withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE)) 614 .withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN)) 615 ); 616 } 617 618 /** 619 * Test GET requests with If-Modified-Since. 620 */ 621 @Test 622 public void testSendRequestIfModifiedSince() throws AcmeException { 623 var ifModifiedSince = ZonedDateTime.now(ZoneId.of("UTC")).truncatedTo(SECONDS); 624 625 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(aResponse() 626 .withStatus(HttpURLConnection.HTTP_NOT_MODIFIED)) 627 ); 628 629 try (var conn = session.connect()) { 630 var rc = conn.sendRequest(requestUrl, session, ifModifiedSince); 631 assertThat(rc).isEqualTo(HttpURLConnection.HTTP_NOT_MODIFIED); 632 } 633 634 verify(getRequestedFor(urlEqualTo(REQUEST_PATH)) 635 .withHeader("If-Modified-Since", equalToDateTime(ifModifiedSince)) 636 .withHeader("Accept", equalTo("application/json")) 637 .withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET)) 638 .withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE)) 639 .withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN)) 640 ); 641 } 642 643 /** 644 * Test signed POST requests. 645 */ 646 @Test 647 public void testSendSignedRequest() throws Exception { 648 var nonce1 = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes()); 649 var nonce2 = URL_ENCODER.encodeToString("foo-nonce-2-foo".getBytes()); 650 651 stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok() 652 .withHeader("Replay-Nonce", nonce1))); 653 654 stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(ok() 655 .withHeader("Replay-Nonce", nonce2) 656 )); 657 658 try (var conn = session.connect()) { 659 var cb = new JSONBuilder(); 660 cb.put("foo", 123).put("bar", "a-string"); 661 conn.sendSignedRequest(requestUrl, cb, login); 662 } 663 664 assertThat(session.getNonce()).isEqualTo(nonce2); 665 666 verify(postRequestedFor(urlEqualTo(REQUEST_PATH)) 667 .withHeader("Accept", equalTo("application/json")) 668 .withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET)) 669 .withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE)) 670 .withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN)) 671 ); 672 673 var requests = findAll(postRequestedFor(urlEqualTo(REQUEST_PATH))); 674 assertThat(requests).hasSize(1); 675 676 var data = JSON.parse(requests.get(0).getBodyAsString()); 677 var encodedHeader = data.get("protected").asString(); 678 var encodedSignature = data.get("signature").asString(); 679 var encodedPayload = data.get("payload").asString(); 680 681 var expectedHeader = new StringBuilder(); 682 expectedHeader.append('{'); 683 expectedHeader.append("\"nonce\":\"").append(nonce1).append("\","); 684 expectedHeader.append("\"url\":\"").append(requestUrl).append("\","); 685 expectedHeader.append("\"alg\":\"RS256\","); 686 expectedHeader.append("\"kid\":\"").append(accountUrl).append('"'); 687 expectedHeader.append('}'); 688 689 assertThatJson(new String(URL_DECODER.decode(encodedHeader), UTF_8)).isEqualTo(expectedHeader.toString()); 690 assertThatJson(new String(URL_DECODER.decode(encodedPayload), UTF_8)).isEqualTo("{\"foo\":123,\"bar\":\"a-string\"}"); 691 assertThat(encodedSignature).isNotEmpty(); 692 693 var jws = new JsonWebSignature(); 694 jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature)); 695 jws.setKey(login.getKeyPair().getPublic()); 696 assertThat(jws.verifySignature()).isTrue(); 697 } 698 699 /** 700 * Test signed POST-as-GET requests. 701 */ 702 @Test 703 public void testSendSignedPostAsGetRequest() throws Exception { 704 var nonce1 = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes()); 705 var nonce2 = URL_ENCODER.encodeToString("foo-nonce-2-foo".getBytes()); 706 707 stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok() 708 .withHeader("Replay-Nonce", nonce1))); 709 710 stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(ok() 711 .withHeader("Replay-Nonce", nonce2))); 712 713 try (var conn = session.connect()) { 714 conn.sendSignedPostAsGetRequest(requestUrl, login); 715 } 716 717 assertThat(session.getNonce()).isEqualTo(nonce2); 718 719 verify(postRequestedFor(urlEqualTo(REQUEST_PATH)) 720 .withHeader("Accept", equalTo("application/json")) 721 .withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET)) 722 .withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE)) 723 .withHeader("Content-Type", equalTo("application/jose+json")) 724 .withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN)) 725 ); 726 727 var requests = findAll(postRequestedFor(urlEqualTo(REQUEST_PATH))); 728 assertThat(requests).hasSize(1); 729 730 var data = JSON.parse(requests.get(0).getBodyAsString()); 731 var encodedHeader = data.get("protected").asString(); 732 var encodedSignature = data.get("signature").asString(); 733 var encodedPayload = data.get("payload").asString(); 734 735 var expectedHeader = new StringBuilder(); 736 expectedHeader.append('{'); 737 expectedHeader.append("\"nonce\":\"").append(nonce1).append("\","); 738 expectedHeader.append("\"url\":\"").append(requestUrl).append("\","); 739 expectedHeader.append("\"alg\":\"RS256\","); 740 expectedHeader.append("\"kid\":\"").append(accountUrl).append('"'); 741 expectedHeader.append('}'); 742 743 assertThatJson(new String(URL_DECODER.decode(encodedHeader), UTF_8)).isEqualTo(expectedHeader.toString()); 744 assertThat(new String(URL_DECODER.decode(encodedPayload), UTF_8)).isEqualTo(""); 745 assertThat(encodedSignature).isNotEmpty(); 746 747 var jws = new JsonWebSignature(); 748 jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature)); 749 jws.setKey(login.getKeyPair().getPublic()); 750 assertThat(jws.verifySignature()).isTrue(); 751 } 752 753 /** 754 * Test certificate POST-as-GET requests. 755 */ 756 @Test 757 public void testSendCertificateRequest() throws AcmeException { 758 var nonce1 = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes()); 759 var nonce2 = URL_ENCODER.encodeToString("foo-nonce-2-foo".getBytes()); 760 761 stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok() 762 .withHeader("Replay-Nonce", nonce1))); 763 764 stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(ok() 765 .withHeader("Replay-Nonce", nonce2))); 766 767 try (var conn = session.connect()) { 768 conn.sendCertificateRequest(requestUrl, login); 769 } 770 771 assertThat(session.getNonce()).isEqualTo(nonce2); 772 773 verify(postRequestedFor(urlEqualTo(REQUEST_PATH)) 774 .withHeader("Accept", equalTo("application/pem-certificate-chain")) 775 .withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET)) 776 .withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE)) 777 .withHeader("Content-Type", equalTo("application/jose+json")) 778 .withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN)) 779 ); 780 } 781 782 /** 783 * Test signed POST requests without KeyIdentifier. 784 */ 785 @Test 786 public void testSendSignedRequestNoKid() throws Exception { 787 var nonce1 = URL_ENCODER.encodeToString("foo-nonce-1-foo".getBytes()); 788 var nonce2 = URL_ENCODER.encodeToString("foo-nonce-2-foo".getBytes()); 789 790 stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(ok() 791 .withHeader("Replay-Nonce", nonce1))); 792 793 stubFor(post(urlEqualTo(REQUEST_PATH)).willReturn(ok() 794 .withHeader("Replay-Nonce", nonce2))); 795 796 try (var conn = session.connect()) { 797 var cb = new JSONBuilder(); 798 cb.put("foo", 123).put("bar", "a-string"); 799 conn.sendSignedRequest(requestUrl, cb, session, keyPair); 800 } 801 802 assertThat(session.getNonce()).isEqualTo(nonce2); 803 804 verify(postRequestedFor(urlEqualTo(REQUEST_PATH)) 805 .withHeader("Accept", equalTo("application/json")) 806 .withHeader("Accept-Charset", equalTo(TEST_ACCEPT_CHARSET)) 807 .withHeader("Accept-Language", equalTo(TEST_ACCEPT_LANGUAGE)) 808 .withHeader("Content-Type", equalTo("application/jose+json")) 809 .withHeader("User-Agent", matching(TEST_USER_AGENT_PATTERN)) 810 ); 811 812 var requests = findAll(postRequestedFor(urlEqualTo(REQUEST_PATH))); 813 assertThat(requests).hasSize(1); 814 815 var data = JSON.parse(requests.get(0).getBodyAsString()); 816 String encodedHeader = data.get("protected").asString(); 817 String encodedSignature = data.get("signature").asString(); 818 String encodedPayload = data.get("payload").asString(); 819 820 var expectedHeader = new StringBuilder(); 821 expectedHeader.append('{'); 822 expectedHeader.append("\"nonce\":\"").append(nonce1).append("\","); 823 expectedHeader.append("\"url\":\"").append(requestUrl).append("\","); 824 expectedHeader.append("\"alg\":\"RS256\","); 825 expectedHeader.append("\"jwk\":{"); 826 expectedHeader.append("\"kty\":\"").append(TestUtils.KTY).append("\","); 827 expectedHeader.append("\"e\":\"").append(TestUtils.E).append("\","); 828 expectedHeader.append("\"n\":\"").append(TestUtils.N).append("\""); 829 expectedHeader.append("}}"); 830 831 assertThatJson(new String(URL_DECODER.decode(encodedHeader), UTF_8)) 832 .isEqualTo(expectedHeader.toString()); 833 assertThatJson(new String(URL_DECODER.decode(encodedPayload), UTF_8)) 834 .isEqualTo("{\"foo\":123,\"bar\":\"a-string\"}"); 835 assertThat(encodedSignature).isNotEmpty(); 836 837 var jws = new JsonWebSignature(); 838 jws.setCompactSerialization(CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature)); 839 jws.setKey(login.getKeyPair().getPublic()); 840 assertThat(jws.verifySignature()).isTrue(); 841 } 842 843 /** 844 * Test signed POST requests if there is no nonce. 845 */ 846 @Test 847 public void testSendSignedRequestNoNonce() { 848 stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(notFound())); 849 850 assertThrows(AcmeException.class, () -> { 851 try (var conn = session.connect()) { 852 conn.sendSignedRequest(requestUrl, new JSONBuilder(), session, keyPair); 853 } 854 }); 855 } 856 857 /** 858 * Test getting a JSON response. 859 */ 860 @Test 861 public void testReadJsonResponse() throws AcmeException { 862 var response = new JSONBuilder(); 863 response.put("foo", 123); 864 response.put("bar", "a-string"); 865 866 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 867 .withHeader("Content-Type", "application/json") 868 .withBody(response.toString()) 869 )); 870 871 try (var conn = session.connect()) { 872 conn.sendRequest(requestUrl, session, null); 873 874 var result = conn.readJsonResponse(); 875 assertThat(result).isNotNull(); 876 assertThat(result.keySet()).hasSize(2); 877 assertThat(result.get("foo").asInt()).isEqualTo(123); 878 assertThat(result.get("bar").asString()).isEqualTo("a-string"); 879 } 880 } 881 882 /** 883 * Test that a certificate is downloaded correctly. 884 */ 885 @Test 886 public void testReadCertificate() throws Exception { 887 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 888 .withHeader("Content-Type", "application/pem-certificate-chain") 889 .withBody(getResourceAsByteArray("/cert.pem")) 890 )); 891 892 List<X509Certificate> downloaded; 893 try (var conn = session.connect()) { 894 conn.sendRequest(requestUrl, session, null); 895 downloaded = conn.readCertificates(); 896 } 897 898 var original = TestUtils.createCertificate("/cert.pem"); 899 assertThat(original).hasSize(2); 900 901 assertThat(downloaded).isNotNull(); 902 assertThat(downloaded).hasSize(original.size()); 903 for (var ix = 0; ix < downloaded.size(); ix++) { 904 assertThat(downloaded.get(ix).getEncoded()).isEqualTo(original.get(ix).getEncoded()); 905 } 906 } 907 908 /** 909 * Test that a bad certificate throws an exception. 910 */ 911 @Test 912 public void testReadBadCertificate() throws Exception { 913 // Build a broken certificate chain PEM file 914 byte[] brokenPem; 915 try (var baos = new ByteArrayOutputStream(); var w = new OutputStreamWriter(baos)) { 916 for (var cert : TestUtils.createCertificate("/cert.pem")) { 917 var badCert = cert.getEncoded(); 918 Arrays.sort(badCert); // break it 919 AcmeUtils.writeToPem(badCert, AcmeUtils.PemLabel.CERTIFICATE, w); 920 } 921 w.flush(); 922 brokenPem = baos.toByteArray(); 923 } 924 925 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 926 .withHeader("Content-Type", "application/pem-certificate-chain") 927 .withBody(brokenPem) 928 )); 929 930 assertThrows(AcmeProtocolException.class, () -> { 931 try (var conn = session.connect()) { 932 conn.sendRequest(requestUrl, session, null); 933 conn.readCertificates(); 934 } 935 }); 936 } 937 938 /** 939 * Test that {@link DefaultConnection#getLastModified()} returns valid dates. 940 */ 941 @Test 942 public void testLastModifiedUnset() throws AcmeException { 943 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok())); 944 945 try (var conn = session.connect()) { 946 conn.sendRequest(requestUrl, session, null); 947 assertThat(conn.getLastModified().isPresent()).isFalse(); 948 } 949 } 950 951 @Test 952 public void testLastModifiedSet() throws AcmeException { 953 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 954 .withHeader("Last-Modified", "Thu, 07 May 2020 19:42:46 GMT") 955 )); 956 957 try (var conn = session.connect()) { 958 conn.sendRequest(requestUrl, session, null); 959 960 var lm = conn.getLastModified(); 961 assertThat(lm.isPresent()).isTrue(); 962 assertThat(lm.get().format(DateTimeFormatter.ISO_DATE_TIME)) 963 .isEqualTo("2020-05-07T19:42:46Z"); 964 } 965 } 966 967 @Test 968 public void testLastModifiedInvalid() throws AcmeException { 969 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 970 .withHeader("Last-Modified", "iNvAlId") 971 )); 972 973 try (var conn = session.connect()) { 974 conn.sendRequest(requestUrl, session, null); 975 assertThat(conn.getLastModified().isPresent()).isFalse(); 976 } 977 } 978 979 /** 980 * Test that {@link DefaultConnection#getExpiration()} returns valid dates. 981 */ 982 @Test 983 public void testExpirationUnset() throws AcmeException { 984 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok())); 985 986 try (var conn = session.connect()) { 987 conn.sendRequest(requestUrl, session, null); 988 assertThat(conn.getExpiration().isPresent()).isFalse(); 989 } 990 } 991 992 @Test 993 public void testExpirationNoCache() throws AcmeException { 994 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 995 .withHeader("Cache-Control", "public, no-cache") 996 )); 997 998 try (var conn = session.connect()) { 999 conn.sendRequest(requestUrl, session, null); 1000 assertThat(conn.getExpiration().isPresent()).isFalse(); 1001 } 1002 } 1003 1004 @Test 1005 public void testExpirationMaxAgeZero() throws AcmeException { 1006 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 1007 .withHeader("Cache-Control", "public, max-age=0, no-cache") 1008 )); 1009 1010 try (var conn = session.connect()) { 1011 conn.sendRequest(requestUrl, session, null); 1012 assertThat(conn.getExpiration().isPresent()).isFalse(); 1013 } 1014 } 1015 1016 @Test 1017 public void testExpirationMaxAgeButNoCache() throws AcmeException { 1018 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 1019 .withHeader("Cache-Control", "public, max-age=3600, no-cache") 1020 )); 1021 1022 try (var conn = session.connect()) { 1023 conn.sendRequest(requestUrl, session, null); 1024 assertThat(conn.getExpiration().isPresent()).isFalse(); 1025 } 1026 } 1027 1028 @Test 1029 public void testExpirationMaxAge() throws AcmeException { 1030 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 1031 .withHeader("Cache-Control", "max-age=3600") 1032 )); 1033 1034 try (var conn = session.connect()) { 1035 conn.sendRequest(requestUrl, session, null); 1036 1037 var exp = conn.getExpiration(); 1038 assertThat(exp.isPresent()).isTrue(); 1039 assertThat(exp.get().isAfter(ZonedDateTime.now().plusHours(1).minusMinutes(1))).isTrue(); 1040 assertThat(exp.get().isBefore(ZonedDateTime.now().plusHours(1).plusMinutes(1))).isTrue(); 1041 } 1042 } 1043 1044 @Test 1045 public void testExpirationExpires() throws AcmeException { 1046 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 1047 .withHeader("Expires", "Thu, 18 Jun 2020 08:43:04 GMT") 1048 )); 1049 1050 try (var conn = session.connect()) { 1051 conn.sendRequest(requestUrl, session, null); 1052 1053 var exp = conn.getExpiration(); 1054 assertThat(exp.isPresent()).isTrue(); 1055 assertThat(exp.get().format(DateTimeFormatter.ISO_DATE_TIME)) 1056 .isEqualTo("2020-06-18T08:43:04Z"); 1057 } 1058 } 1059 1060 @Test 1061 public void testExpirationInvalidExpires() throws AcmeException { 1062 stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() 1063 .withHeader("Expires", "iNvAlId") 1064 )); 1065 1066 try (var conn = session.connect()) { 1067 conn.sendRequest(requestUrl, session, null); 1068 assertThat(conn.getExpiration().isPresent()).isFalse(); 1069 } 1070 } 1071 1072}