001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2016 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.toolbox;
015
016import static org.assertj.core.api.Assertions.assertThat;
017import static org.assertj.core.api.Assertions.within;
018import static org.junit.jupiter.api.Assertions.assertThrows;
019import static org.shredzone.acme4j.toolbox.AcmeUtils.*;
020
021import java.io.ByteArrayOutputStream;
022import java.io.IOException;
023import java.io.OutputStreamWriter;
024import java.io.Writer;
025import java.lang.reflect.Modifier;
026import java.net.URI;
027import java.security.Security;
028import java.security.cert.CertificateEncodingException;
029import java.time.temporal.ChronoUnit;
030import java.util.Locale;
031import java.util.stream.Stream;
032
033import org.bouncycastle.jce.provider.BouncyCastleProvider;
034import org.junit.jupiter.api.BeforeAll;
035import org.junit.jupiter.api.Test;
036import org.junit.jupiter.params.ParameterizedTest;
037import org.junit.jupiter.params.provider.Arguments;
038import org.junit.jupiter.params.provider.MethodSource;
039import org.junit.jupiter.params.provider.NullSource;
040import org.junit.jupiter.params.provider.ValueSource;
041import org.shredzone.acme4j.exception.AcmeProtocolException;
042
043/**
044 * Unit tests for {@link AcmeUtils}.
045 */
046public class AcmeUtilsTest {
047
048    @BeforeAll
049    public static void setup() {
050        Security.addProvider(new BouncyCastleProvider());
051    }
052
053    /**
054     * Test that constructor is private.
055     */
056    @Test
057    public void testPrivateConstructor() throws Exception {
058        var constructor = AcmeUtils.class.getDeclaredConstructor();
059        assertThat(Modifier.isPrivate(constructor.getModifiers())).isTrue();
060        constructor.setAccessible(true);
061        constructor.newInstance();
062    }
063
064    /**
065     * Test sha-256 hash and hex encode.
066     */
067    @Test
068    public void testSha256HashHexEncode() {
069        var hexEncode = hexEncode(sha256hash("foobar"));
070        assertThat(hexEncode).isEqualTo("c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2");
071    }
072
073    /**
074     * Test base64 URL encode.
075     */
076    @Test
077    public void testBase64UrlEncode() {
078        var base64UrlEncode = base64UrlEncode(sha256hash("foobar"));
079        assertThat(base64UrlEncode).isEqualTo("w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI");
080    }
081
082    /**
083     * Test base64 URL decode.
084     */
085    @Test
086    public void testBase64UrlDecode() {
087        var base64UrlDecode = base64UrlDecode("w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI");
088        assertThat(base64UrlDecode).isEqualTo(sha256hash("foobar"));
089    }
090
091    /**
092     * Test base64 URL validation for valid values
093     */
094    @ParameterizedTest
095    @ValueSource(strings = {
096            "",
097            "Zg",
098            "Zm9v",
099    })
100    public void testBase64UrlValid(String url) {
101        assertThat(isValidBase64Url(url)).isTrue();
102    }
103
104    /**
105     * Test base64 URL validation for invalid values
106     */
107    @ParameterizedTest
108    @ValueSource(strings = {
109            "         ",
110            "Zg=",
111            "Zg==",
112            "   Zm9v   ",
113            "<some>.illegal#Text",
114    })
115    @NullSource
116    public void testBase64UrlInvalid(String url) {
117        assertThat(isValidBase64Url(url)).isFalse();
118    }
119
120    /**
121     * Test ACE conversion.
122     */
123    @Test
124    public void testToAce() {
125        // Test ASCII domains in different notations
126        assertThat(toAce("example.com")).isEqualTo("example.com");
127        assertThat(toAce("   example.com  ")).isEqualTo("example.com");
128        assertThat(toAce("ExAmPlE.CoM")).isEqualTo("example.com");
129        assertThat(toAce("foo.example.com")).isEqualTo("foo.example.com");
130        assertThat(toAce("bar.foo.example.com")).isEqualTo("bar.foo.example.com");
131
132        // Test IDN domains
133        assertThat(toAce("ExÄmþle.¢öM")).isEqualTo("xn--exmle-hra7p.xn--m-7ba6w");
134
135        // Test alternate separators
136        assertThat(toAce("example\u3002com")).isEqualTo("example.com");
137        assertThat(toAce("example\uff0ecom")).isEqualTo("example.com");
138        assertThat(toAce("example\uff61com")).isEqualTo("example.com");
139
140        // Test ACE encoded domains, they must not change
141        assertThat(toAce("xn--exmle-hra7p.xn--m-7ba6w"))
142                .isEqualTo("xn--exmle-hra7p.xn--m-7ba6w");
143    }
144
145    /**
146     * Test valid strings.
147     */
148    @ParameterizedTest
149    @MethodSource("provideTimestamps")
150    public void testParser(String input, String expected) {
151        Arguments.of(input, expected, within(1, ChronoUnit.MILLIS));
152    }
153
154    private static Stream<Arguments> provideTimestamps() {
155        return Stream.of(
156            Arguments.of("2015-12-27T22:58:35.006769519Z", "2015-12-27T22:58:35.006Z"),
157            Arguments.of("2015-12-27T22:58:35.00676951Z", "2015-12-27T22:58:35.006Z"),
158            Arguments.of("2015-12-27T22:58:35.0067695Z", "2015-12-27T22:58:35.006Z"),
159            Arguments.of("2015-12-27T22:58:35.006769Z", "2015-12-27T22:58:35.006Z"),
160            Arguments.of("2015-12-27T22:58:35.00676Z", "2015-12-27T22:58:35.006Z"),
161            Arguments.of("2015-12-27T22:58:35.0067Z", "2015-12-27T22:58:35.006Z"),
162            Arguments.of("2015-12-27T22:58:35.006Z", "2015-12-27T22:58:35.006Z"),
163            Arguments.of("2015-12-27T22:58:35.01Z", "2015-12-27T22:58:35.010Z"),
164            Arguments.of("2015-12-27T22:58:35.2Z", "2015-12-27T22:58:35.200Z"),
165            Arguments.of("2015-12-27T22:58:35Z", "2015-12-27T22:58:35.000Z"),
166            Arguments.of("2015-12-27t22:58:35z", "2015-12-27T22:58:35.000Z"),
167
168            Arguments.of("2015-12-27T22:58:35.006769519+02:00", "2015-12-27T20:58:35.006Z"),
169            Arguments.of("2015-12-27T22:58:35.006+02:00", "2015-12-27T20:58:35.006Z"),
170            Arguments.of("2015-12-27T22:58:35+02:00", "2015-12-27T20:58:35.000Z"),
171
172            Arguments.of("2015-12-27T21:58:35.006769519-02:00", "2015-12-27T23:58:35.006Z"),
173            Arguments.of("2015-12-27T21:58:35.006-02:00", "2015-12-27T23:58:35.006Z"),
174            Arguments.of("2015-12-27T21:58:35-02:00", "2015-12-27T23:58:35.000Z"),
175
176            Arguments.of("2015-12-27T22:58:35.006769519+0200", "2015-12-27T20:58:35.006Z"),
177            Arguments.of("2015-12-27T22:58:35.006+0200", "2015-12-27T20:58:35.006Z"),
178            Arguments.of("2015-12-27T22:58:35+0200", "2015-12-27T20:58:35.000Z"),
179
180            Arguments.of("2015-12-27T21:58:35.006769519-0200", "2015-12-27T23:58:35.006Z"),
181            Arguments.of("2015-12-27T21:58:35.006-0200", "2015-12-27T23:58:35.006Z"),
182            Arguments.of("2015-12-27T21:58:35-0200", "2015-12-27T23:58:35.000Z")
183        );
184    }
185
186    /**
187     * Test invalid strings.
188     */
189    @Test
190    public void testInvalid() {
191        assertThrows(IllegalArgumentException.class,
192                () -> parseTimestamp(""),
193                "accepted empty string");
194        assertThrows(IllegalArgumentException.class,
195                () -> parseTimestamp("abc"),
196                "accepted nonsense string");
197        assertThrows(IllegalArgumentException.class,
198                () -> parseTimestamp("2015-12-27"),
199                "accepted date only string");
200        assertThrows(IllegalArgumentException.class,
201                () -> parseTimestamp("2015-12-27T"),
202                "accepted string without time");
203    }
204
205    /**
206     * Test that locales are correctly converted to language headers.
207     */
208    @Test
209    public void testLocaleToLanguageHeader() {
210        assertThat(localeToLanguageHeader(Locale.ENGLISH))
211                .isEqualTo("en,*;q=0.1");
212        assertThat(localeToLanguageHeader(new Locale("en", "US")))
213                .isEqualTo("en-US,en;q=0.8,*;q=0.1");
214        assertThat(localeToLanguageHeader(Locale.GERMAN))
215                .isEqualTo("de,*;q=0.1");
216        assertThat(localeToLanguageHeader(Locale.GERMANY))
217                .isEqualTo("de-DE,de;q=0.8,*;q=0.1");
218        assertThat(localeToLanguageHeader(new Locale("")))
219                .isEqualTo("*");
220        assertThat(localeToLanguageHeader(null))
221                .isEqualTo("*");
222    }
223
224    /**
225     * Test that error prefix is correctly removed.
226     */
227    @Test
228    public void testStripErrorPrefix() {
229        assertThat(stripErrorPrefix("urn:ietf:params:acme:error:unauthorized")).isEqualTo("unauthorized");
230        assertThat(stripErrorPrefix("urn:somethingelse:error:message")).isNull();
231        assertThat(stripErrorPrefix(null)).isNull();
232    }
233
234    /**
235     * Test that {@link AcmeUtils#writeToPem(byte[], PemLabel, Writer)} writes a correct PEM
236     * file.
237     */
238    @Test
239    public void testWriteToPem() throws IOException, CertificateEncodingException {
240        var certChain = TestUtils.createCertificate("/cert.pem");
241
242        var pemFile = new ByteArrayOutputStream();
243        try (var w = new OutputStreamWriter(pemFile)) {
244            for (var cert : certChain) {
245                AcmeUtils.writeToPem(cert.getEncoded(), AcmeUtils.PemLabel.CERTIFICATE, w);
246            }
247        }
248
249        var originalFile = new ByteArrayOutputStream();
250        try (var in = getClass().getResourceAsStream("/cert.pem")) {
251            var buffer = new byte[2048];
252            int len;
253            while ((len = in.read(buffer)) >= 0) {
254                originalFile.write(buffer, 0, len);
255            }
256        }
257
258        assertThat(pemFile.toByteArray()).isEqualTo(originalFile.toByteArray());
259    }
260
261    /**
262     * Test {@link AcmeUtils#getContentType(String)} for JSON types.
263     */
264    @ParameterizedTest
265    @ValueSource(strings = {
266            "application/json",
267            "application/json; charset=utf-8",
268            "application/json; charset=utf-8 (Plain text)",
269            "application/json; charset=\"utf-8\"",
270            "application/json; charset=\"UTF-8\"; foo=4",
271            " application/json ;foo=4",
272    })
273    public void testGetContentTypeForJson(String contentType) {
274        assertThat(AcmeUtils.getContentType(contentType)).isEqualTo("application/json");
275    }
276
277    /**
278     * Test {@link AcmeUtils#getContentType(String)} with other types.
279     */
280    @Test
281    public void testGetContentType() {
282        assertThat(AcmeUtils.getContentType(null)).isNull();
283        assertThat(AcmeUtils.getContentType("Application/Problem+JSON"))
284                .isEqualTo("application/problem+json");
285        assertThrows(AcmeProtocolException.class,
286                () -> AcmeUtils.getContentType("application/json; charset=\"iso-8859-1\""));
287    }
288
289    /**
290     * Test that {@link AcmeUtils#validateContact(java.net.URI)} refuses invalid
291     * contacts.
292     */
293    @Test
294    public void testValidateContact() {
295        AcmeUtils.validateContact(URI.create("mailto:foo@example.com"));
296
297        assertThrows(IllegalArgumentException.class,
298                () -> AcmeUtils.validateContact(URI.create("mailto:foo@example.com,bar@example.com")),
299                "multiple recipients are accepted");
300        assertThrows(IllegalArgumentException.class,
301                () -> AcmeUtils.validateContact(URI.create("mailto:foo@example.com?to=bar@example.com")),
302                "hfields are accepted");
303        assertThrows(IllegalArgumentException.class,
304                () -> AcmeUtils.validateContact(URI.create("mailto:?to=foo@example.com")),
305                "only hfields are accepted");
306    }
307
308}