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}