Add unit tests for AbstractAcmeClient

pull/17/merge
Richard Körber 2015-12-18 00:31:55 +01:00
parent 4c02421114
commit 251e5af317
11 changed files with 608 additions and 44 deletions

View File

@ -47,7 +47,7 @@ public class DnsChallengeTest {
Account account = new Account(keypair);
DnsChallenge challenge = new DnsChallenge();
challenge.unmarshall(TestUtils.getResourceAsJsonMap("/dnsChallenge.json"));
challenge.unmarshall(TestUtils.getJsonAsMap("dnsChallenge"));
assertThat(challenge.getType(), is(DnsChallenge.TYPE));
assertThat(challenge.getStatus(), is(Status.PENDING));

View File

@ -43,7 +43,7 @@ public class GenericChallengeTest {
* Test that after unmarshalling, the challenge properties are set correctly.
*/
@Test
public void testUnmarshall() throws IOException, URISyntaxException {
public void testUnmarshall() throws URISyntaxException {
GenericChallenge challenge = new GenericChallenge();
// Test default values
@ -53,7 +53,7 @@ public class GenericChallengeTest {
assertThat(challenge.getValidated(), is(nullValue()));
// Unmarshall a challenge JSON
challenge.unmarshall(TestUtils.getResourceAsJsonMap("/genericChallenge.json"));
challenge.unmarshall(TestUtils.getJsonAsMap("genericChallenge"));
// Test unmarshalled values
assertThat(challenge.getType(), is("generic-01"));
@ -85,8 +85,8 @@ public class GenericChallengeTest {
* unmarshalled.
*/
@Test
public void testMarshall() throws IOException, JoseException {
String json = TestUtils.getResourceAsString("/genericChallenge.json");
public void testMarshall() throws JoseException {
String json = TestUtils.getJson("genericChallenge");
GenericChallenge challenge = new GenericChallenge();
challenge.unmarshall(JsonUtil.parseJson(json));

View File

@ -47,7 +47,7 @@ public class HttpChallengeTest {
Account account = new Account(keypair);
HttpChallenge challenge = new HttpChallenge();
challenge.unmarshall(TestUtils.getResourceAsJsonMap("/httpChallenge.json"));
challenge.unmarshall(TestUtils.getJsonAsMap("httpChallenge"));
assertThat(challenge.getType(), is(HttpChallenge.TYPE));
assertThat(challenge.getStatus(), is(Status.PENDING));

View File

@ -0,0 +1,386 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.impl;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import static org.shredzone.acme4j.util.TestUtils.*;
import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.cert.X509Certificate;
import java.util.HashMap;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import org.shredzone.acme4j.Account;
import org.shredzone.acme4j.Authorization;
import org.shredzone.acme4j.Registration;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.challenge.Challenge.Status;
import org.shredzone.acme4j.challenge.DnsChallenge;
import org.shredzone.acme4j.challenge.GenericChallenge;
import org.shredzone.acme4j.challenge.HttpChallenge;
import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.connector.Session;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.util.ClaimBuilder;
import org.shredzone.acme4j.util.TestUtils;
/**
* Unit tests for {@link AbstractAcmeClient}.
*
* @author Richard "Shred" Körber
*/
public class AbstractAcmeClientTest {
private Account testAccount;
private URI resourceUri;
private URI locationUri;
private URI agreementUri;
@Before
public void setup() throws IOException, URISyntaxException {
resourceUri = new URI("https://example.com/acme/some-resource");
locationUri = new URI("https://example.com/acme/some-location");
agreementUri = new URI("http://example.com/agreement.pdf");
testAccount = new Account(TestUtils.createKeyPair());
}
/**
* Test that a new {@link Registration} can be registered.
*/
@Test
public void testNewRegistration() throws AcmeException {
Registration registration = new Registration();
registration.getContacts().add("mailto:foo@example.com");
Connection connection = new DummyConnection() {
@Override
public int sendSignedRequest(URI uri, ClaimBuilder claims, Session session, Account account) throws AcmeException {
assertThat(uri, is(resourceUri));
assertThat(claims.toString(), sameJSONAs(getJson("newRegistration")));
assertThat(session, is(notNullValue()));
assertThat(account, is(sameInstance(testAccount)));
return HttpURLConnection.HTTP_CREATED;
}
@Override
public URI getLocation() throws AcmeException {
return locationUri;
}
@Override
public URI getLink(String relation) throws AcmeException {
switch(relation) {
case "terms-of-service": return agreementUri;
default: return null;
}
}
};
TestableAbstractAcmeClient client = new TestableAbstractAcmeClient(connection);
client.putTestResource(Resource.NEW_REG, resourceUri);
client.newRegistration(testAccount, registration);
assertThat(registration.getLocation(), is(locationUri));
assertThat(registration.getAgreement(), is(agreementUri));
}
/**
* Test that a {@link Registration} can be updated.
*/
@Test
public void testUpdateRegistration() throws AcmeException {
Registration registration = new Registration();
registration.setAgreement(agreementUri);
registration.getContacts().add("mailto:foo2@example.com");
registration.setLocation(locationUri);
Connection connection = new DummyConnection() {
@Override
public int sendSignedRequest(URI uri, ClaimBuilder claims, Session session, Account account) throws AcmeException {
assertThat(uri, is(locationUri));
assertThat(claims.toString(), sameJSONAs(getJson("updateRegistration")));
assertThat(session, is(notNullValue()));
assertThat(account, is(sameInstance(testAccount)));
return HttpURLConnection.HTTP_ACCEPTED;
}
@Override
public URI getLocation() throws AcmeException {
return locationUri;
}
@Override
public URI getLink(String relation) throws AcmeException {
switch(relation) {
case "terms-of-service": return agreementUri;
default: return null;
}
}
};
TestableAbstractAcmeClient client = new TestableAbstractAcmeClient(connection);
client.updateRegistration(testAccount, registration);
assertThat(registration.getLocation(), is(locationUri));
assertThat(registration.getAgreement(), is(agreementUri));
}
/**
* Test that a new {@link Authorization} can be created.
*/
@Test
public void testNewAuthorization() throws AcmeException {
Authorization auth = new Authorization();
auth.setDomain("example.org");
Connection connection = new DummyConnection() {
@Override
public int sendSignedRequest(URI uri, ClaimBuilder claims, Session session, Account account) throws AcmeException {
assertThat(uri, is(resourceUri));
assertThat(claims.toString(), sameJSONAs(getJson("newAuthorizationRequest")));
assertThat(session, is(notNullValue()));
assertThat(account, is(sameInstance(testAccount)));
return HttpURLConnection.HTTP_CREATED;
}
@Override
public Map<String, Object> readJsonResponse() throws AcmeException {
return getJsonAsMap("newAuthorizationResponse");
}
};
HttpChallenge httpChallenge = new HttpChallenge();
DnsChallenge dnsChallenge = new DnsChallenge();
TestableAbstractAcmeClient client = new TestableAbstractAcmeClient(connection);
client.putTestResource(Resource.NEW_AUTHZ, resourceUri);
client.putTestChallenge("http-01", httpChallenge);
client.putTestChallenge("dns-01", dnsChallenge);
client.newAuthorization(testAccount, auth);
assertThat(auth.getDomain(), is("example.org"));
assertThat(auth.getStatus(), is("pending"));
assertThat(auth.getExpires(), is(nullValue()));
assertThat(auth.getChallenges(), containsInAnyOrder(
(Challenge) httpChallenge, (Challenge) dnsChallenge));
assertThat(auth.getCombinations(), hasSize(2));
assertThat(auth.getCombinations().get(0), containsInAnyOrder(
(Challenge) httpChallenge));
assertThat(auth.getCombinations().get(1), containsInAnyOrder(
(Challenge) httpChallenge, (Challenge) dnsChallenge));
}
/**
* Test that a {@link Challenge} can be triggered.
*/
@Test
public void testTriggerChallenge() throws AcmeException {
Connection connection = new DummyConnection() {
@Override
public int sendSignedRequest(URI uri, ClaimBuilder claims, Session session, Account account) throws AcmeException {
assertThat(uri, is(resourceUri));
assertThat(claims.toString(), sameJSONAs(getJson("triggerHttpChallengeRequest")));
assertThat(session, is(notNullValue()));
assertThat(account, is(sameInstance(testAccount)));
return HttpURLConnection.HTTP_ACCEPTED;
}
@Override
public Map<String, Object> readJsonResponse() throws AcmeException {
return getJsonAsMap("triggerHttpChallengeResponse");
}
};
TestableAbstractAcmeClient client = new TestableAbstractAcmeClient(connection);
Challenge challenge = new HttpChallenge();
challenge.unmarshall(getJsonAsMap("triggerHttpChallenge"));
challenge.authorize(testAccount);
client.triggerChallenge(testAccount, challenge);
assertThat(challenge.getStatus(), is(Status.PENDING));
assertThat(challenge.getUri(), is(locationUri));
}
/**
* Test that a {@link Challenge} is properly updated.
*/
@Test
public void testUpdateChallenge() throws AcmeException {
Connection connection = new DummyConnection() {
@Override
public int sendRequest(URI uri) throws AcmeException {
assertThat(uri, is(locationUri));
return HttpURLConnection.HTTP_ACCEPTED;
}
@Override
public Map<String, Object> readJsonResponse() throws AcmeException {
return getJsonAsMap("updateHttpChallengeResponse");
}
};
TestableAbstractAcmeClient client = new TestableAbstractAcmeClient(connection);
Challenge challenge = new HttpChallenge();
challenge.unmarshall(getJsonAsMap("triggerHttpChallengeResponse"));
client.updateChallenge(testAccount, challenge);
assertThat(challenge.getStatus(), is(Status.VALID));
assertThat(challenge.getUri(), is(locationUri));
}
/**
* Test that a certificate can be requested.
*/
@Test
public void testRequestCertificate() throws AcmeException, IOException {
Connection connection = new DummyConnection() {
@Override
public int sendSignedRequest(URI uri, ClaimBuilder claims, Session session, Account account) throws AcmeException {
assertThat(uri, is(resourceUri));
assertThat(claims.toString(), sameJSONAs(getJson("requestCertificateRequest")));
assertThat(session, is(notNullValue()));
assertThat(account, is(sameInstance(testAccount)));
return HttpURLConnection.HTTP_CREATED;
}
@Override
public URI getLocation() throws AcmeException {
return locationUri;
}
};
TestableAbstractAcmeClient client = new TestableAbstractAcmeClient(connection);
client.putTestResource(Resource.NEW_CERT, resourceUri);
byte[] csr = TestUtils.getResourceAsByteArray("/csr.der");
URI certUri = client.requestCertificate(testAccount, csr);
assertThat(certUri, is(locationUri));
}
/**
* Test that a certificate can be downloaded.
*/
@Test
public void testDownloadCertificate() throws AcmeException, IOException {
final X509Certificate originalCert = TestUtils.createCertificate();
Connection connection = new DummyConnection() {
@Override
public int sendRequest(URI uri) throws AcmeException {
assertThat(uri, is(locationUri));
return HttpURLConnection.HTTP_OK;
}
@Override
public X509Certificate readCertificate() throws AcmeException {
return originalCert;
}
};
TestableAbstractAcmeClient client = new TestableAbstractAcmeClient(connection);
X509Certificate downloadedCert = client.downloadCertificate(locationUri);
assertThat(downloadedCert, is(sameInstance(originalCert)));
}
/**
* Extends the {@link AbstractAcmeClient} to be tested, and implements the abstract
* methods with a simple implementation specially made for testing purposes.
*/
public static class TestableAbstractAcmeClient extends AbstractAcmeClient {
private final Map<Resource, URI> resourceMap = new HashMap<>();
private final Map<String, Challenge> challengeMap = new HashMap<>();
private final Connection connection;
private boolean connected = false;
public TestableAbstractAcmeClient(Connection connection) {
this.connection = connection;
}
/**
* Register a {@link Resource} mapping.
*
* @param r
* {@link Resource} to be mapped
* @param u
* {@link URI} to be returned
*/
public void putTestResource(Resource r, URI u) {
resourceMap.put(r, u);
}
/**
* Register a {@link Challenge}. For the sake of simplicity,
* {@link #createChallenge(String)} will always return the same {@link Challenge}
* instance in this test suite.
*
* @param s
* Challenge type
* @param c
* {@link Challenge} instance.
*/
public void putTestChallenge(String s, Challenge c) {
challengeMap.put(s, c);
}
@Override
protected URI resourceUri(Resource resource) throws AcmeException {
if (resourceMap.isEmpty()) {
fail("Unexpected invocation of resourceUri()");
}
URI resUri = resourceMap.get(resource);
if (resUri == null) {
fail("Unexpected invocation of resourceUri() with resource " + resource.name());
}
return resUri;
}
@Override
protected Challenge createChallenge(String type) {
if (challengeMap.isEmpty()) {
fail("Unexpected invocation of createChallenge()");
}
Challenge challenge = challengeMap.get(type);
return (challenge != null ? challenge : new GenericChallenge());
}
@Override
protected Connection createConnection() {
if (connected) {
fail("createConnection() invoked twice");
}
connected = true;
return connection;
}
}
}

View File

@ -0,0 +1,85 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2015 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.impl;
import java.net.URI;
import java.security.cert.X509Certificate;
import java.util.Map;
import org.shredzone.acme4j.Account;
import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.connector.Session;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.util.ClaimBuilder;
/**
* Dummy implementation of {@link Connection} that always fails. Single methods are
* supposed to be overridden for testing.
*
* @author Richard "Shred" Körber
*/
public class DummyConnection implements Connection {
@Override
public void startSession(URI uri, Session session) throws AcmeException {
throw new UnsupportedOperationException();
}
@Override
public int sendRequest(URI uri) throws AcmeException {
throw new UnsupportedOperationException();
}
@Override
public int sendSignedRequest(URI uri, ClaimBuilder claims, Session session, Account account) throws AcmeException {
throw new UnsupportedOperationException();
}
@Override
public Map<String, Object> readJsonResponse() throws AcmeException {
throw new UnsupportedOperationException();
}
@Override
public X509Certificate readCertificate() throws AcmeException {
throw new UnsupportedOperationException();
}
@Override
public Map<Resource, URI> readDirectory() throws AcmeException {
throw new UnsupportedOperationException();
}
@Override
public URI getLocation() throws AcmeException {
throw new UnsupportedOperationException();
}
@Override
public URI getLink(String relation) throws AcmeException {
throw new UnsupportedOperationException();
}
@Override
public void throwAcmeException() throws AcmeException {
throw new UnsupportedOperationException();
}
@Override
public void close() {
// closing is always safe
}
}

View File

@ -17,8 +17,6 @@ import java.io.ByteArrayOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringWriter;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
@ -33,6 +31,7 @@ import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.TreeMap;
import org.jose4j.base64url.Base64Url;
@ -52,6 +51,8 @@ public final class TestUtils {
public static final String KTY = "RSA";
public static final String THUMBPRINT = "HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0";
private static final ResourceBundle JSON_RESOURCE = ResourceBundle.getBundle("json");
private TestUtils() {
// utility class without constructor
}
@ -76,36 +77,28 @@ public final class TestUtils {
}
/**
* Reads a resource as String.
* Reads a JSON string from {@code json.properties}.
*
* @param name
* Resource name. The content is expected to be utf-8 encoded.
* @param key
* JSON resource
* @return Resource contents as string
*/
public static String getResourceAsString(String name) throws IOException {
try (InputStreamReader in = new InputStreamReader(
TestUtils.class.getResourceAsStream(name), "utf-8");
StringWriter out = new StringWriter()) {
int ch;
while ((ch = in.read()) >= 0) {
out.write(ch);
}
return out.toString();
}
public static String getJson(String key) {
return JSON_RESOURCE.getString(key);
}
/**
* Reads a JSON resource and parses it.
* Reads a JSON string from {@code json.properties} and parses it.
*
* @param name
* Resource name of a utf-8 encoded JSON file.
* @return Parsed contents
* @param key
* JSON resource
* @return Parsed JSON resource
*/
public static Map<String, Object> getResourceAsJsonMap(String name) throws IOException {
public static Map<String, Object> getJsonAsMap(String key) {
try {
return JsonUtil.parseJson(getResourceAsString(name));
return JsonUtil.parseJson(getJson(key));
} catch (JoseException ex) {
throw new IOException("JSON error", ex);
throw new RuntimeException("JSON error", ex);
}
}

Binary file not shown.

View File

@ -1,5 +0,0 @@
{
"type":"dns-01",
"status":"pending",
"token": "pNvmJivs0WCko2suV7fhe-59oFqyYx_yB7tx6kIMAyE"
}

View File

@ -1,6 +0,0 @@
{
"type":"generic-01",
"status":"valid",
"uri":"http://example.com/challenge/123",
"validated":"2015-12-12T17:19:36.336785823Z"
}

View File

@ -1,5 +0,0 @@
{
"type":"http-01",
"status":"pending",
"token": "rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ"
}

View File

@ -0,0 +1,116 @@
#
# acme4j - Java ACME client
#
# Copyright (C) 2015 Richard "Shred" Körber
# http://acme4j.shredzone.org
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
newRegistration = \
{"resource":"new-reg",\
"contact":["mailto:foo@example.com"]}
updateRegistration = \
{"resource":"reg",\
"agreement":"http://example.com/agreement.pdf",\
"contact":["mailto:foo2@example.com"]}
newAuthorizationRequest = \
{"resource":"new-authz",\
"identifier":{"type":"dns","value":"example.org"}}
newAuthorizationResponse = \
{\
"status": "pending",\
"identifier": {\
"type": "dns",\
"value": "example.org"\
},\
"challenges": [\
{\
"type": "http-01",\
"status":"pending",\
"uri": "https://example.com/authz/asdf/0",\
"token": "IlirfxKKXAsHtmzK29Pj8A"\
},\
{\
"type": "dns-01",\
"status":"pending",\
"uri": "https://example.com/authz/asdf/1",\
"token": "DGyRejmCefe7v4NfDGDKfA"\
}\
],\
"combinations": [[0], [0,1]]\
}
triggerHttpChallenge = \
{\
"type": "http-01",\
"status":"pending",\
"uri": "https://example.com/acme/some-resource",\
"token": "IlirfxKKXAsHtmzK29Pj8A"\
}
triggerHttpChallengeRequest = \
{\
"resource": "challenge",\
"type": "http-01",\
"token": "IlirfxKKXAsHtmzK29Pj8A",\
"keyAuthorization":"IlirfxKKXAsHtmzK29Pj8A.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0"\
}
triggerHttpChallengeResponse = \
{\
"type":"http-01",\
"status":"pending",\
"uri":"https://example.com/acme/some-location",\
"token": "IlirfxKKXAsHtmzK29Pj8A"\
"keyAuthorization":"XbmEGDDc2AMDArHLt5x7GxZfIRv0aScknUKlyf5S4KU.KMH_h8aGAKlY3VQqBUczm1cfo9kaovivy59rSY1xZ0E"\
}
updateHttpChallengeResponse = \
{\
"type":"http-01",\
"status":"valid",\
"uri":"https://example.com/acme/some-location",\
"token": "IlirfxKKXAsHtmzK29Pj8A"\
"keyAuthorization":"XbmEGDDc2AMDArHLt5x7GxZfIRv0aScknUKlyf5S4KU.KMH_h8aGAKlY3VQqBUczm1cfo9kaovivy59rSY1xZ0E"\
}
requestCertificateRequest = \
{\
"csr":"MIIChDCCAWwCAQAwFjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCPemmumcNGR0hsPo-2N6nkJ0FcEMdb0_MlucHR0dNeHEvn8vmcQHlYRjkDVX0aypnfKQI3tvhTBKLdlNvbVIW1TQ_Wbqh9TQlC8G3Hog8nRQ2vAzO4sH6nhvdrAFUmq6hkATpU3iQuDvtYu03ExaYHKsItLocl1OndaQizBn5udBv1baOW3Kd790k6lEWGrD-TXo6uwuMha2k_YBGNKd4S4UuPmbPV9SUVW8JSylBSgDhvY3BHv-dfdIMhVwRMZDFaa0mHDIYUiwcEaU5x4P6Q5bGP2wxcUPCLwFsbAK5K6B2T2P3A2fNjGBAlHwEkg6VMvi7jax8MD-oRnku2M2JLAgMBAAGgKTAnBgkqhkiG9w0BCQ4xGjAYMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQACnMZdjE1jVqnkHkEeGrMXujWuwuiKLZVa4YZ9fL0UIGOqqf4b9-3JmtEiLB9ycQO5N9rW4V-6_DBMeoeRBLu-wranHnxU4ds6GzNzBxKfI86_8t5pdQK4Cglv7yfseseZRdQtvcR2ejkW0F3SL1DF5Sk3T46aRYiUXxeoNC4Uh3zoIHOv8YGUa-DuZQ6OnHMhPrdsfU09L7KVAMTq1bodjGWmgoIJm4x5JSm19GbhYAm9Q9XWnN56YHqgS3FtS9n3wDxz7Dvo24whto1tUU5hnjrp31rTvyxG3kydoEZf2Ciq_82bQDb40kwnoO6RytPYJVMRIBsP2mCfaFtIt9Eb",\
"resource":"new-cert"\
}
dnsChallenge = \
{ \
"type":"dns-01", \
"status":"pending", \
"token": "pNvmJivs0WCko2suV7fhe-59oFqyYx_yB7tx6kIMAyE" \
}
genericChallenge = \
{ \
"type":"generic-01", \
"status":"valid", \
"uri":"http://example.com/challenge/123", \
"validated":"2015-12-12T17:19:36.336785823Z" \
}
httpChallenge = \
{ \
"type":"http-01", \
"status":"pending", \
"token": "rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ" \
}
#