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.provider;
015
016import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
017import static org.assertj.core.api.Assertions.assertThat;
018import static org.junit.jupiter.api.Assertions.assertThrows;
019import static org.mockito.Mockito.*;
020import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
021
022import java.net.HttpURLConnection;
023import java.net.URI;
024import java.net.URL;
025import java.time.ZonedDateTime;
026import java.time.temporal.ChronoUnit;
027import java.util.Optional;
028import java.util.concurrent.atomic.AtomicBoolean;
029
030import org.junit.jupiter.api.Test;
031import org.shredzone.acme4j.Login;
032import org.shredzone.acme4j.Session;
033import org.shredzone.acme4j.challenge.Challenge;
034import org.shredzone.acme4j.challenge.Dns01Challenge;
035import org.shredzone.acme4j.challenge.Http01Challenge;
036import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
037import org.shredzone.acme4j.challenge.TokenChallenge;
038import org.shredzone.acme4j.connector.Connection;
039import org.shredzone.acme4j.connector.DefaultConnection;
040import org.shredzone.acme4j.connector.HttpConnector;
041import org.shredzone.acme4j.connector.NetworkSettings;
042import org.shredzone.acme4j.exception.AcmeException;
043import org.shredzone.acme4j.exception.AcmeProtocolException;
044import org.shredzone.acme4j.toolbox.JSONBuilder;
045import org.shredzone.acme4j.toolbox.TestUtils;
046
047/**
048 * Unit tests for {@link AbstractAcmeProvider}.
049 */
050public class AbstractAcmeProviderTest {
051
052    private static final URI SERVER_URI = URI.create("http://example.com/acme");
053    private static final URL RESOLVED_URL = TestUtils.url("http://example.com/acme/directory");
054    private static final NetworkSettings NETWORK_SETTINGS = new NetworkSettings();
055
056    /**
057     * Test that connect returns a connection.
058     */
059    @Test
060    public void testConnect() {
061        var invoked = new AtomicBoolean();
062
063        var provider = new TestAbstractAcmeProvider() {
064            @Override
065            protected HttpConnector createHttpConnector(NetworkSettings settings) {
066                assertThat(settings).isSameAs(NETWORK_SETTINGS);
067                invoked.set(true);
068                return super.createHttpConnector(settings);
069            }
070        };
071
072        var connection = provider.connect(SERVER_URI, NETWORK_SETTINGS);
073        assertThat(connection).isNotNull();
074        assertThat(connection).isInstanceOf(DefaultConnection.class);
075        assertThat(invoked).isTrue();
076    }
077
078    /**
079     * Verify that the resources directory is read.
080     */
081    @Test
082    public void testResources() throws AcmeException {
083        var connection = mock(Connection.class);
084        var session = mock(Session.class);
085
086        when(connection.readJsonResponse()).thenReturn(getJSON("directory"));
087
088        var provider = new TestAbstractAcmeProvider(connection);
089        var map = provider.directory(session, SERVER_URI);
090
091        assertThatJson(map.toString()).isEqualTo(TestUtils.getJSON("directory").toString());
092
093        verify(connection).sendRequest(RESOLVED_URL, session, null);
094        verify(connection).getNonce();
095        verify(connection).getLastModified();
096        verify(connection).getExpiration();
097        verify(connection).readJsonResponse();
098        verify(connection).close();
099        verifyNoMoreInteractions(connection);
100    }
101
102    /**
103     * Verify that the cache control headers are evaluated.
104     */
105    @Test
106    public void testResourcesCacheControl() throws AcmeException {
107        var lastModified = ZonedDateTime.now().minus(13, ChronoUnit.DAYS);
108        var expiryDate = ZonedDateTime.now().plus(60, ChronoUnit.DAYS);
109
110        var connection = mock(Connection.class);
111        var session = mock(Session.class);
112
113        when(connection.readJsonResponse()).thenReturn(getJSON("directory"));
114        when(connection.getLastModified()).thenReturn(Optional.of(lastModified));
115        when(connection.getExpiration()).thenReturn(Optional.of(expiryDate));
116        when(session.getDirectoryExpires()).thenReturn(null);
117        when(session.getDirectoryLastModified()).thenReturn(null);
118
119        var provider = new TestAbstractAcmeProvider(connection);
120        var map = provider.directory(session, SERVER_URI);
121
122        assertThatJson(map.toString()).isEqualTo(TestUtils.getJSON("directory").toString());
123
124        verify(session).setDirectoryLastModified(eq(lastModified));
125        verify(session).setDirectoryExpires(eq(expiryDate));
126        verify(session).getDirectoryExpires();
127        verify(session).getDirectoryLastModified();
128        verify(session).networkSettings();
129        verifyNoMoreInteractions(session);
130
131        verify(connection).sendRequest(RESOLVED_URL, session, null);
132        verify(connection).getNonce();
133        verify(connection).getLastModified();
134        verify(connection).getExpiration();
135        verify(connection).readJsonResponse();
136        verify(connection).close();
137        verifyNoMoreInteractions(connection);
138    }
139
140    /**
141     * Verify that resorces are not fetched if not yet expired.
142     */
143    @Test
144    public void testResourcesNotExprired() throws AcmeException {
145        var expiryDate = ZonedDateTime.now().plus(60, ChronoUnit.DAYS);
146
147        var connection = mock(Connection.class);
148        var session = mock(Session.class);
149
150        when(session.getDirectoryExpires()).thenReturn(expiryDate);
151
152        var provider = new TestAbstractAcmeProvider();
153        var map = provider.directory(session, SERVER_URI);
154
155        assertThat(map).isNull();
156
157        verify(session).getDirectoryExpires();
158        verifyNoMoreInteractions(session);
159
160        verifyNoMoreInteractions(connection);
161    }
162
163    /**
164     * Verify that resorces are fetched if expired.
165     */
166    @Test
167    public void testResourcesExprired() throws AcmeException {
168        var expiryDate = ZonedDateTime.now().plus(60, ChronoUnit.DAYS);
169        var pastExpiryDate = ZonedDateTime.now().minus(10, ChronoUnit.MINUTES);
170
171        var connection = mock(Connection.class);
172        var session = mock(Session.class);
173
174        when(connection.readJsonResponse()).thenReturn(getJSON("directory"));
175        when(connection.getExpiration()).thenReturn(Optional.of(expiryDate));
176        when(connection.getLastModified()).thenReturn(Optional.empty());
177        when(session.getDirectoryExpires()).thenReturn(pastExpiryDate);
178
179        var provider = new TestAbstractAcmeProvider(connection);
180        var map = provider.directory(session, SERVER_URI);
181
182        assertThatJson(map.toString()).isEqualTo(TestUtils.getJSON("directory").toString());
183
184        verify(session).setDirectoryExpires(eq(expiryDate));
185        verify(session).setDirectoryLastModified(eq(null));
186        verify(session).getDirectoryExpires();
187        verify(session).getDirectoryLastModified();
188        verify(session).networkSettings();
189        verifyNoMoreInteractions(session);
190
191        verify(connection).sendRequest(RESOLVED_URL, session, null);
192        verify(connection).getNonce();
193        verify(connection).getLastModified();
194        verify(connection).getExpiration();
195        verify(connection).readJsonResponse();
196        verify(connection).close();
197        verifyNoMoreInteractions(connection);
198    }
199
200    /**
201     * Verify that if-modified-since is used.
202     */
203    @Test
204    public void testResourcesIfModifiedSince() throws AcmeException {
205        var modifiedSinceDate = ZonedDateTime.now().minus(60, ChronoUnit.DAYS);
206
207        var connection = mock(Connection.class);
208        var session = mock(Session.class);
209
210        when(connection.sendRequest(eq(RESOLVED_URL), eq(session), eq(modifiedSinceDate)))
211                .thenReturn(HttpURLConnection.HTTP_NOT_MODIFIED);
212        when(connection.getLastModified()).thenReturn(Optional.of(modifiedSinceDate));
213        when(session.getDirectoryLastModified()).thenReturn(modifiedSinceDate);
214
215        var provider = new TestAbstractAcmeProvider(connection);
216        var map = provider.directory(session, SERVER_URI);
217
218        assertThat(map).isNull();
219
220        verify(session).getDirectoryExpires();
221        verify(session).getDirectoryLastModified();
222        verify(session).networkSettings();
223        verifyNoMoreInteractions(session);
224
225        verify(connection).sendRequest(RESOLVED_URL, session, modifiedSinceDate);
226        verify(connection).close();
227        verifyNoMoreInteractions(connection);
228    }
229
230    /**
231     * Test that challenges are generated properly.
232     */
233    @Test
234    public void testCreateChallenge() {
235        var login = mock(Login.class);
236
237        var provider = new TestAbstractAcmeProvider();
238
239        var c1 = provider.createChallenge(login, getJSON("httpChallenge"));
240        assertThat(c1).isNotNull();
241        assertThat(c1).isInstanceOf(Http01Challenge.class);
242
243        var c2 = provider.createChallenge(login, getJSON("httpChallenge"));
244        assertThat(c2).isNotSameAs(c1);
245
246        var c3 = provider.createChallenge(login, getJSON("dnsChallenge"));
247        assertThat(c3).isNotNull();
248        assertThat(c3).isInstanceOf(Dns01Challenge.class);
249
250        var c4 = provider.createChallenge(login, getJSON("tlsAlpnChallenge"));
251        assertThat(c4).isNotNull();
252        assertThat(c4).isInstanceOf(TlsAlpn01Challenge.class);
253
254        var json6 = new JSONBuilder()
255                    .put("type", "foobar-01")
256                    .put("url", "https://example.com/some/challenge")
257                    .toJSON();
258        var c6 = provider.createChallenge(login, json6);
259        assertThat(c6).isNotNull();
260        assertThat(c6).isInstanceOf(Challenge.class);
261
262        var json7 = new JSONBuilder()
263                        .put("type", "foobar-01")
264                        .put("token", "abc123")
265                        .put("url", "https://example.com/some/challenge")
266                        .toJSON();
267        var c7 = provider.createChallenge(login, json7);
268        assertThat(c7).isNotNull();
269        assertThat(c7).isInstanceOf(TokenChallenge.class);
270
271        assertThrows(AcmeProtocolException.class, () -> {
272            var json8 = new JSONBuilder()
273                    .put("url", "https://example.com/some/challenge")
274                    .toJSON();
275            provider.createChallenge(login, json8);
276        });
277
278        assertThrows(NullPointerException.class, () -> provider.createChallenge(login, null));
279    }
280
281    private static class TestAbstractAcmeProvider extends AbstractAcmeProvider {
282        private final Connection connection;
283
284        public TestAbstractAcmeProvider() {
285            this.connection = null;
286        }
287
288        public TestAbstractAcmeProvider(Connection connection) {
289            this.connection = connection;
290        }
291
292        @Override
293        public boolean accepts(URI serverUri) {
294            assertThat(serverUri).isEqualTo(SERVER_URI);
295            return true;
296        }
297
298        @Override
299        public URL resolve(URI serverUri) {
300            assertThat(serverUri).isEqualTo(SERVER_URI);
301            return RESOLVED_URL;
302        }
303
304        @Override
305        public Connection connect(URI serverUri, NetworkSettings networkSettings) {
306            assertThat(serverUri).isEqualTo(SERVER_URI);
307            return connection != null ? connection : super.connect(serverUri, networkSettings);
308        }
309    }
310
311}