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;
015
016import static java.util.Collections.emptyList;
017import static java.util.Collections.singletonList;
018import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
019import static org.assertj.core.api.Assertions.*;
020import static org.junit.jupiter.api.Assertions.assertThrows;
021import static org.junit.jupiter.api.Assertions.fail;
022import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
023import static org.shredzone.acme4j.toolbox.TestUtils.url;
024
025import java.io.IOException;
026import java.net.HttpURLConnection;
027import java.net.URI;
028import java.net.URL;
029import java.util.Collection;
030import java.util.concurrent.atomic.AtomicBoolean;
031
032import org.jose4j.jws.JsonWebSignature;
033import org.jose4j.jwx.CompactSerializer;
034import org.jose4j.lang.JoseException;
035import org.junit.jupiter.api.Test;
036import org.shredzone.acme4j.challenge.Dns01Challenge;
037import org.shredzone.acme4j.challenge.Http01Challenge;
038import org.shredzone.acme4j.connector.Resource;
039import org.shredzone.acme4j.exception.AcmeException;
040import org.shredzone.acme4j.exception.AcmeNotSupportedException;
041import org.shredzone.acme4j.exception.AcmeServerException;
042import org.shredzone.acme4j.provider.TestableConnectionProvider;
043import org.shredzone.acme4j.toolbox.JSON;
044import org.shredzone.acme4j.toolbox.JSONBuilder;
045import org.shredzone.acme4j.toolbox.TestUtils;
046
047/**
048 * Unit tests for {@link Account}.
049 */
050public class AccountTest {
051
052    private final URL resourceUrl  = url("http://example.com/acme/resource");
053    private final URL locationUrl  = url(TestUtils.ACCOUNT_URL);
054    private final URL agreementUrl = url("http://example.com/agreement.pdf");
055
056    /**
057     * Test that a account can be updated.
058     */
059    @Test
060    public void testUpdateAccount() throws AcmeException, IOException {
061        var provider = new TestableConnectionProvider() {
062            private JSON jsonResponse;
063
064            @Override
065            public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
066                assertThat(url).isEqualTo(locationUrl);
067                assertThatJson(claims.toString()).isEqualTo(getJSON("updateAccount").toString());
068                assertThat(login).isNotNull();
069                jsonResponse = getJSON("updateAccountResponse");
070                return HttpURLConnection.HTTP_OK;
071            }
072
073            @Override
074            public int sendSignedPostAsGetRequest(URL url, Login login) {
075                if ("https://example.com/acme/acct/1/orders".equals(url.toExternalForm())) {
076                    jsonResponse = new JSONBuilder()
077                                .array("orders", singletonList("https://example.com/acme/order/1"))
078                                .toJSON();
079                } else {
080                    jsonResponse = getJSON("updateAccountResponse");
081                }
082                return HttpURLConnection.HTTP_OK;
083            }
084
085            @Override
086            public JSON readJsonResponse() {
087                return jsonResponse;
088            }
089
090            @Override
091            public URL getLocation() {
092                return locationUrl;
093            }
094
095            @Override
096            public Collection<URL> getLinks(String relation) {
097                return emptyList();
098            }
099        };
100
101        var login = provider.createLogin();
102        var account = new Account(login);
103        account.update();
104
105        assertThat(login.getAccountLocation()).isEqualTo(locationUrl);
106        assertThat(account.getLocation()).isEqualTo(locationUrl);
107        assertThat(account.getTermsOfServiceAgreed().orElseThrow()).isTrue();
108        assertThat(account.getContacts()).hasSize(1);
109        assertThat(account.getContacts().get(0)).isEqualTo(URI.create("mailto:foo2@example.com"));
110        assertThat(account.getStatus()).isEqualTo(Status.VALID);
111        assertThat(account.hasExternalAccountBinding()).isTrue();
112        assertThat(account.getKeyIdentifier().orElseThrow()).isEqualTo("NCC-1701");
113
114        var orderIt = account.getOrders();
115        assertThat(orderIt).isNotNull();
116        assertThat(orderIt.next().getLocation()).isEqualTo(url("https://example.com/acme/order/1"));
117        assertThat(orderIt.hasNext()).isFalse();
118
119        provider.close();
120    }
121
122    /**
123     * Test lazy loading.
124     */
125    @Test
126    public void testLazyLoading() throws IOException {
127        var requestWasSent = new AtomicBoolean(false);
128
129        var provider = new TestableConnectionProvider() {
130            @Override
131            public int sendSignedPostAsGetRequest(URL url, Login login) {
132                requestWasSent.set(true);
133                assertThat(url).isEqualTo(locationUrl);
134                return HttpURLConnection.HTTP_OK;
135            }
136
137            @Override
138            public JSON readJsonResponse() {
139                return getJSON("updateAccountResponse");
140            }
141
142            @Override
143            public URL getLocation() {
144                return locationUrl;
145            }
146
147            @Override
148            public Collection<URL> getLinks(String relation) {
149                switch(relation) {
150                    case "termsOfService": return singletonList(agreementUrl);
151                    default: return emptyList();
152                }
153            }
154        };
155
156        var account = new Account(provider.createLogin());
157
158        // Lazy loading
159        assertThat(requestWasSent.get()).isFalse();
160        assertThat(account.getTermsOfServiceAgreed().orElseThrow()).isTrue();
161        assertThat(requestWasSent.get()).isTrue();
162
163        // Subsequent queries do not trigger another load
164        requestWasSent.set(false);
165        assertThat(account.getTermsOfServiceAgreed().orElseThrow()).isTrue();
166        assertThat(account.getStatus()).isEqualTo(Status.VALID);
167        assertThat(requestWasSent.get()).isFalse();
168
169        provider.close();
170    }
171
172    /**
173     * Test that a domain can be pre-authorized.
174     */
175    @Test
176    public void testPreAuthorizeDomain() throws Exception {
177        var provider = new TestableConnectionProvider() {
178            @Override
179            public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
180                assertThat(url).isEqualTo(resourceUrl);
181                assertThatJson(claims.toString()).isEqualTo(getJSON("newAuthorizationRequest").toString());
182                assertThat(login).isNotNull();
183                return HttpURLConnection.HTTP_CREATED;
184            }
185
186            @Override
187            public JSON readJsonResponse() {
188                return getJSON("newAuthorizationResponse");
189            }
190
191            @Override
192            public URL getLocation() {
193                return locationUrl;
194            }
195        };
196
197        var login = provider.createLogin();
198
199        provider.putTestResource(Resource.NEW_AUTHZ, resourceUrl);
200        provider.putTestChallenge(Http01Challenge.TYPE, Http01Challenge::new);
201        provider.putTestChallenge(Dns01Challenge.TYPE, Dns01Challenge::new);
202
203        var domainName = "example.org";
204
205        var account = new Account(login);
206        var auth = account.preAuthorize(Identifier.dns(domainName));
207
208        assertThat(auth.getIdentifier().getDomain()).isEqualTo(domainName);
209        assertThat(auth.getStatus()).isEqualTo(Status.PENDING);
210        assertThat(auth.getExpires()).isEmpty();
211        assertThat(auth.getLocation()).isEqualTo(locationUrl);
212
213        assertThat(auth.getChallenges()).containsExactlyInAnyOrder(
214                        provider.getChallenge(Http01Challenge.TYPE),
215                        provider.getChallenge(Dns01Challenge.TYPE));
216
217        provider.close();
218    }
219
220    /**
221     * Test that pre-authorization with subdomains fails if not supported.
222     */
223    @Test
224    public void testPreAuthorizeDomainSubdomainsFails() throws Exception {
225        var provider = new TestableConnectionProvider();
226
227        var login = provider.createLogin();
228
229        provider.putTestResource(Resource.NEW_AUTHZ, resourceUrl);
230
231        assertThat(login.getSession().getMetadata().isSubdomainAuthAllowed()).isFalse();
232
233        var account = new Account(login);
234
235        assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() ->
236                account.preAuthorize(Identifier.dns("example.org").allowSubdomainAuth())
237        );
238
239        provider.close();
240    }
241
242    /**
243     * Test that a domain can be pre-authorized, with allowed subdomains.
244     */
245    @Test
246    public void testPreAuthorizeDomainSubdomains() throws Exception {
247        var provider = new TestableConnectionProvider() {
248            @Override
249            public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
250                assertThat(url).isEqualTo(resourceUrl);
251                assertThatJson(claims.toString()).isEqualTo(getJSON("newAuthorizationRequestSub").toString());
252                assertThat(login).isNotNull();
253                return HttpURLConnection.HTTP_CREATED;
254            }
255
256            @Override
257            public JSON readJsonResponse() {
258                return getJSON("newAuthorizationResponseSub");
259            }
260
261            @Override
262            public URL getLocation() {
263                return locationUrl;
264            }
265        };
266
267        var login = provider.createLogin();
268
269        provider.putMetadata("subdomainAuthAllowed", true);
270        provider.putTestResource(Resource.NEW_AUTHZ, resourceUrl);
271        provider.putTestChallenge(Dns01Challenge.TYPE, Dns01Challenge::new);
272
273        var domainName = "example.org";
274
275        var account = new Account(login);
276        var auth = account.preAuthorize(Identifier.dns(domainName).allowSubdomainAuth());
277
278        assertThat(login.getSession().getMetadata().isSubdomainAuthAllowed()).isTrue();
279        assertThat(auth.getIdentifier().getDomain()).isEqualTo(domainName);
280        assertThat(auth.getStatus()).isEqualTo(Status.PENDING);
281        assertThat(auth.getExpires()).isEmpty();
282        assertThat(auth.getLocation()).isEqualTo(locationUrl);
283        assertThat(auth.isSubdomainAuthAllowed()).isTrue();
284
285        assertThat(auth.getChallenges()).containsExactlyInAnyOrder(
286                provider.getChallenge(Dns01Challenge.TYPE));
287
288        provider.close();
289    }
290
291    /**
292     * Test that a domain pre-authorization can fail.
293     */
294    @Test
295    public void testNoPreAuthorizeDomain() throws Exception {
296        var problemType = URI.create("urn:ietf:params:acme:error:rejectedIdentifier");
297        var problemDetail = "example.org is blacklisted";
298
299        var provider = new TestableConnectionProvider() {
300            @Override
301            public int sendSignedRequest(URL url, JSONBuilder claims, Login login) throws AcmeException {
302                assertThat(url).isEqualTo(resourceUrl);
303                assertThatJson(claims.toString()).isEqualTo(getJSON("newAuthorizationRequest").toString());
304                assertThat(login).isNotNull();
305
306                var problem = TestUtils.createProblem(problemType, problemDetail, resourceUrl);
307                throw new AcmeServerException(problem);
308            }
309        };
310
311        var login = provider.createLogin();
312
313        provider.putTestResource(Resource.NEW_AUTHZ, resourceUrl);
314
315        var account = new Account(login);
316
317        var ex = assertThrows(AcmeServerException.class, () ->
318            account.preAuthorizeDomain("example.org")
319        );
320        assertThat(ex.getType()).isEqualTo(problemType);
321        assertThat(ex.getMessage()).isEqualTo(problemDetail);
322
323        provider.close();
324    }
325
326    /**
327     * Test that a bad domain parameter is not accepted.
328     */
329    @Test
330    public void testAuthorizeBadDomain() throws Exception {
331        var provider = new TestableConnectionProvider();
332        // just provide a resource record so the provider returns a directory
333        provider.putTestResource(Resource.NEW_NONCE, resourceUrl);
334
335        var login = provider.createLogin();
336        var account = login.getAccount();
337
338        assertThatNullPointerException()
339                .isThrownBy(() -> account.preAuthorizeDomain(null));
340        assertThatIllegalArgumentException()
341                .isThrownBy(() -> account.preAuthorizeDomain(""));
342        assertThatExceptionOfType(AcmeNotSupportedException.class)
343                .isThrownBy(() -> account.preAuthorizeDomain("example.com"))
344                .withMessage("Server does not support newAuthz");
345
346        provider.close();
347    }
348
349    /**
350     * Test that the account key can be changed.
351     */
352    @Test
353    public void testChangeKey() throws Exception {
354        var oldKeyPair = TestUtils.createKeyPair();
355        var newKeyPair = TestUtils.createDomainKeyPair();
356
357        var provider = new TestableConnectionProvider() {
358            @Override
359            public int sendSignedRequest(URL url, JSONBuilder payload, Login login) {
360                try {
361                    assertThat(url).isEqualTo(locationUrl);
362                    assertThat(login).isNotNull();
363                    assertThat(login.getKeyPair()).isSameAs(oldKeyPair);
364
365                    var json = payload.toJSON();
366                    var encodedHeader = json.get("protected").asString();
367                    var encodedSignature = json.get("signature").asString();
368                    var encodedPayload = json.get("payload").asString();
369
370                    var serialized = CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature);
371                    var jws = new JsonWebSignature();
372                    jws.setCompactSerialization(serialized);
373                    jws.setKey(newKeyPair.getPublic());
374                    assertThat(jws.verifySignature()).isTrue();
375
376                    var decodedPayload = jws.getPayload();
377
378                    var expectedPayload = new StringBuilder();
379                    expectedPayload.append('{');
380                    expectedPayload.append("\"account\":\"").append(locationUrl).append("\",");
381                    expectedPayload.append("\"oldKey\":{");
382                    expectedPayload.append("\"kty\":\"").append(TestUtils.KTY).append("\",");
383                    expectedPayload.append("\"e\":\"").append(TestUtils.E).append("\",");
384                    expectedPayload.append("\"n\":\"").append(TestUtils.N).append("\"");
385                    expectedPayload.append("}}");
386                    assertThatJson(decodedPayload).isEqualTo(expectedPayload.toString());
387                } catch (JoseException ex) {
388                    fail(ex);
389                }
390
391                return HttpURLConnection.HTTP_OK;
392            }
393
394            @Override
395            public URL getLocation() {
396                return locationUrl;
397            }
398        };
399
400        provider.putTestResource(Resource.KEY_CHANGE, locationUrl);
401
402        var session = TestUtils.session(provider);
403        var login = new Login(locationUrl, oldKeyPair, session);
404
405        assertThat(login.getKeyPair()).isSameAs(oldKeyPair);
406
407        var account = new Account(login);
408        account.changeKey(newKeyPair);
409
410        assertThat(login.getKeyPair()).isSameAs(newKeyPair);
411    }
412
413    /**
414     * Test that the same account key is not accepted for change.
415     */
416    @Test
417    public void testChangeSameKey() {
418        assertThrows(IllegalArgumentException.class, () -> {
419            var provider = new TestableConnectionProvider();
420            var login = provider.createLogin();
421
422            var account = new Account(login);
423            account.changeKey(login.getKeyPair());
424
425            provider.close();
426        });
427    }
428
429    /**
430     * Test that an account can be deactivated.
431     */
432    @Test
433    public void testDeactivate() throws Exception {
434        var provider = new TestableConnectionProvider() {
435            @Override
436            public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
437                var json = claims.toJSON();
438                assertThat(json.get("status").asString()).isEqualTo("deactivated");
439                assertThat(url).isEqualTo(locationUrl);
440                assertThat(login).isNotNull();
441                return HttpURLConnection.HTTP_OK;
442            }
443
444            @Override
445            public JSON readJsonResponse() {
446                return getJSON("deactivateAccountResponse");
447            }
448        };
449
450        var account = new Account(provider.createLogin());
451        account.deactivate();
452
453        assertThat(account.getStatus()).isEqualTo(Status.DEACTIVATED);
454
455        provider.close();
456    }
457
458    /**
459     * Test that a new order can be created.
460     */
461    @Test
462    public void testNewOrder() throws AcmeException, IOException {
463        var provider = new TestableConnectionProvider();
464        var login = provider.createLogin();
465
466        var account = new Account(login);
467        assertThat(account.newOrder()).isNotNull();
468
469        provider.close();
470    }
471
472    /**
473     * Test that an account can be modified.
474     */
475    @Test
476    public void testModify() throws Exception {
477        var provider = new TestableConnectionProvider() {
478            @Override
479            public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
480                assertThat(url).isEqualTo(locationUrl);
481                assertThatJson(claims.toString()).isEqualTo(getJSON("modifyAccount").toString());
482                assertThat(login).isNotNull();
483                return HttpURLConnection.HTTP_OK;
484            }
485
486            @Override
487            public JSON readJsonResponse() {
488                return getJSON("modifyAccountResponse");
489            }
490
491            @Override
492            public URL getLocation() {
493                return locationUrl;
494            }
495        };
496
497        var account = new Account(provider.createLogin());
498        account.setJSON(getJSON("newAccount"));
499
500        var editable = account.modify();
501        assertThat(editable).isNotNull();
502
503        editable.addContact("mailto:foo2@example.com");
504        editable.getContacts().add(URI.create("mailto:foo3@example.com"));
505        editable.commit();
506
507        assertThat(account.getLocation()).isEqualTo(locationUrl);
508        assertThat(account.getContacts()).hasSize(3);
509        assertThat(account.getContacts()).element(0).isEqualTo(URI.create("mailto:foo@example.com"));
510        assertThat(account.getContacts()).element(1).isEqualTo(URI.create("mailto:foo2@example.com"));
511        assertThat(account.getContacts()).element(2).isEqualTo(URI.create("mailto:foo3@example.com"));
512
513        provider.close();
514    }
515
516}