Skip to content

Commit 936ff14

Browse files
committed
Adds integration test for APIConnecector
1 parent f99efb2 commit 936ff14

8 files changed

Lines changed: 243 additions & 30 deletions

File tree

api/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
mockserver_keystore*

api/pom.xml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,31 @@
2121
<artifactId>junit-jupiter-api</artifactId>
2222
<scope>test</scope>
2323
</dependency>
24+
<dependency>
25+
<groupId>org.junit.jupiter</groupId>
26+
<artifactId>junit-jupiter-params</artifactId>
27+
<scope>test</scope>
28+
</dependency>
2429
<dependency>
2530
<groupId>org.assertj</groupId>
2631
<artifactId>assertj-core</artifactId>
2732
<scope>test</scope>
2833
</dependency>
34+
<dependency>
35+
<groupId>org.mock-server</groupId>
36+
<artifactId>mockserver-junit-jupiter-no-dependencies</artifactId>
37+
<scope>test</scope>
38+
</dependency>
39+
<dependency>
40+
<groupId>org.mock-server</groupId>
41+
<artifactId>mockserver-client-java-no-dependencies</artifactId>
42+
<scope>test</scope>
43+
</dependency>
44+
<dependency>
45+
<groupId>org.mockito</groupId>
46+
<artifactId>mockito-junit-jupiter</artifactId>
47+
<scope>test</scope>
48+
</dependency>
2949
<dependency>
3050
<groupId>commons-io</groupId>
3151
<artifactId>commons-io</artifactId>
@@ -47,6 +67,10 @@
4767
<groupId>eu.neilalexander</groupId>
4868
<artifactId>jnacl</artifactId>
4969
</dependency>
70+
<dependency>
71+
<groupId>org.apache.logging.log4j</groupId>
72+
<artifactId>log4j-core</artifactId>
73+
</dependency>
5074
</dependencies>
5175
<distributionManagement>
5276
<repository>

api/src/main/java/net/klesatschke/threema/api/APIConnector.java

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,31 @@
3232
import java.io.InputStream;
3333
import java.io.InputStreamReader;
3434
import java.io.UnsupportedEncodingException;
35+
import java.net.URI;
3536
import java.net.URL;
3637
import java.net.URLEncoder;
38+
import java.net.http.HttpClient;
39+
import java.net.http.HttpClient.Redirect;
40+
import java.net.http.HttpRequest;
41+
import java.net.http.HttpResponse;
42+
import java.net.http.HttpResponse.BodyHandlers;
3743
import java.nio.charset.StandardCharsets;
3844
import java.security.SecureRandom;
3945
import java.util.Map;
46+
import java.util.Optional;
4047

4148
import javax.net.ssl.HttpsURLConnection;
4249

50+
import lombok.extern.log4j.Log4j2;
51+
import net.klesatschke.threema.api.exceptions.ClientError;
52+
import net.klesatschke.threema.api.exceptions.ServerError;
53+
import net.klesatschke.threema.api.exceptions.ThreemaError;
4354
import net.klesatschke.threema.api.results.CapabilityResult;
4455
import net.klesatschke.threema.api.results.EncryptResult;
4556
import net.klesatschke.threema.api.results.UploadResult;
4657

4758
/** Facilitates HTTPS communication with the Threema Message API. */
59+
@Log4j2
4860
public class APIConnector {
4961
private static final int BUFFER_SIZE = 16384;
5062

@@ -72,6 +84,7 @@ public InputStreamLength(InputStream inputStream, int length) {
7284
private final PublicKeyStore publicKeyStore;
7385
private final String apiIdentity;
7486
private final String secret;
87+
private HttpClient httpClient;
7588

7689
public APIConnector(String apiIdentity, String secret, PublicKeyStore publicKeyStore) {
7790
this(apiIdentity, secret, "https://msgapi.threema.ch/", publicKeyStore);
@@ -83,6 +96,7 @@ public APIConnector(
8396
this.secret = secret;
8497
this.apiUrl = apiUrl;
8598
this.publicKeyStore = publicKeyStore;
99+
httpClient = HttpClient.newBuilder().followRedirects(Redirect.NEVER).build();
86100
}
87101

88102
/**
@@ -129,17 +143,27 @@ public String sendE2EMessage(String to, byte[] nonce, byte[] box) throws IOExcep
129143
* @throws IOException if a communication or server error occurs
130144
*/
131145
public String lookupPhone(String phoneNumber) throws IOException {
146+
var getParams = makeRequestParams();
147+
var phoneHash = CryptTool.hashPhoneNo(phoneNumber);
132148

133149
try {
134-
var getParams = makeRequestParams();
135-
136-
var phoneHash = CryptTool.hashPhoneNo(phoneNumber);
137-
138150
return doGet(
139-
new URL(this.apiUrl + "lookup/phone_hash/" + DataUtils.byteArrayToHexString(phoneHash)),
151+
URI.create(
152+
this.apiUrl + "lookup/phone_hash/" + DataUtils.byteArrayToHexString(phoneHash)),
140153
getParams);
141-
} catch (FileNotFoundException e) {
142-
return null;
154+
} catch (ThreemaError e) {
155+
switch (e.getResponse().statusCode()) {
156+
case 400:
157+
throw new ClientError(e.getResponse(), "the hash length is wrong");
158+
case 401:
159+
throw new ClientError(e.getResponse(), "API identity or secret are incorrect");
160+
case 404:
161+
throw new ClientError(e.getResponse(), "no matching ID could be found");
162+
case 500:
163+
throw new ServerError(e.getResponse(), "a temporary internal server error occurs");
164+
default:
165+
throw e;
166+
}
143167
}
144168
}
145169

@@ -159,7 +183,8 @@ public String lookupEmail(String email) throws IOException {
159183
var emailHash = CryptTool.hashEmail(email);
160184

161185
return doGet(
162-
new URL(this.apiUrl + "lookup/email_hash/" + DataUtils.byteArrayToHexString(emailHash)),
186+
URI.create(
187+
this.apiUrl + "lookup/email_hash/" + DataUtils.byteArrayToHexString(emailHash)),
163188
getParams);
164189
} catch (FileNotFoundException e) {
165190
return null;
@@ -178,7 +203,7 @@ public byte[] lookupKey(String id) throws IOException {
178203
if (key == null) {
179204
try {
180205
var getParams = makeRequestParams();
181-
var pubkeyHex = doGet(new URL(this.apiUrl + "pubkeys/" + id), getParams);
206+
var pubkeyHex = doGet(URI.create(this.apiUrl + "pubkeys/" + id), getParams);
182207
key = DataUtils.hexStringToByteArray(pubkeyHex);
183208

184209
if (key != null) {
@@ -199,15 +224,15 @@ public byte[] lookupKey(String id) throws IOException {
199224
* @throws IOException
200225
*/
201226
public CapabilityResult lookupKeyCapability(String threemaId) throws IOException {
202-
var res = doGet(new URL(this.apiUrl + "capabilities/" + threemaId), makeRequestParams());
227+
var res = doGet(URI.create(this.apiUrl + "capabilities/" + threemaId), makeRequestParams());
203228
if (res != null) {
204229
return new CapabilityResult(threemaId, res.split(","));
205230
}
206231
return null;
207232
}
208233

209234
public Integer lookupCredits() throws IOException {
210-
var res = doGet(new URL(this.apiUrl + "credits"), makeRequestParams());
235+
var res = doGet(URI.create(this.apiUrl + "credits"), makeRequestParams());
211236
if (res != null) {
212237
return Integer.valueOf(res);
213238
}
@@ -358,29 +383,28 @@ private Map<String, String> makeRequestParams() {
358383
return Map.of("from", apiIdentity, "secret", secret);
359384
}
360385

361-
private String doGet(URL url, Map<String, String> getParams) throws IOException {
386+
private String doGet(URI uri, Map<String, String> getParams) throws IOException {
387+
var uriWithParameters =
388+
Optional.ofNullable(getParams)
389+
.map(params -> URI.create(uri.toString() + "?" + makeUrlEncoded(params)))
390+
.orElse(uri);
362391

363-
if (getParams != null) {
364-
var queryString = makeUrlEncoded(getParams);
365-
366-
url = new URL(url.toString() + "?" + queryString);
367-
}
368-
369-
var connection = (HttpsURLConnection) url.openConnection();
370-
connection.setDoOutput(false);
371-
connection.setDoInput(true);
372-
connection.setInstanceFollowRedirects(false);
373-
connection.setRequestMethod("GET");
374-
connection.setUseCaches(false);
392+
var httpRequest = HttpRequest.newBuilder().uri(uriWithParameters).GET().build();
393+
try {
394+
HttpResponse<String> response = httpClient.send(httpRequest, BodyHandlers.ofString());
375395

376-
var is = connection.getInputStream();
377-
var br = new BufferedReader(new InputStreamReader(is));
378-
var response = br.readLine();
379-
br.close();
396+
var statusCode = response.statusCode();
397+
if (400 <= statusCode) {
398+
throw new ThreemaError(response);
399+
}
380400

381-
connection.disconnect();
401+
return response.body();
382402

383-
return response;
403+
} catch (InterruptedException e) {
404+
log.warn("Interrupted");
405+
Thread.currentThread().interrupt();
406+
}
407+
return null;
384408
}
385409

386410
private String doPost(URL url, Map<String, String> postParams) throws IOException {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package net.klesatschke.threema.api.exceptions;
2+
3+
import java.net.http.HttpResponse;
4+
5+
public class ClientError extends ThreemaError {
6+
private static final long serialVersionUID = 5586830942850922017L;
7+
8+
public ClientError(HttpResponse<?> response, String string) {
9+
super(response, string);
10+
}
11+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package net.klesatschke.threema.api.exceptions;
2+
3+
import java.net.http.HttpResponse;
4+
5+
public class ServerError extends ThreemaError {
6+
7+
private static final long serialVersionUID = 1306889474607193102L;
8+
9+
public ServerError(HttpResponse<?> response) {
10+
super(response);
11+
}
12+
13+
public ServerError(HttpResponse<?> response, String string) {
14+
super(response, string);
15+
}
16+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package net.klesatschke.threema.api.exceptions;
2+
3+
import java.io.IOException;
4+
import java.net.http.HttpResponse;
5+
6+
import lombok.AllArgsConstructor;
7+
import lombok.Getter;
8+
9+
@AllArgsConstructor
10+
public class ThreemaError extends IOException {
11+
private static final long serialVersionUID = -5089520812601719876L;
12+
@Getter private final HttpResponse<?> response;
13+
14+
public ThreemaError(HttpResponse<?> response, String string) {
15+
super(string);
16+
this.response = response;
17+
}
18+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package net.klesatschke.threema.api;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatIOException;
5+
import static org.mockserver.model.HttpRequest.request;
6+
import static org.mockserver.model.HttpResponse.response;
7+
8+
import java.io.IOException;
9+
10+
import javax.net.ssl.HttpsURLConnection;
11+
12+
import org.junit.jupiter.api.BeforeAll;
13+
import org.junit.jupiter.api.BeforeEach;
14+
import org.junit.jupiter.api.Test;
15+
import org.junit.jupiter.api.extension.ExtendWith;
16+
import org.junit.jupiter.params.ParameterizedTest;
17+
import org.junit.jupiter.params.provider.ValueSource;
18+
import org.mockito.Mock;
19+
import org.mockito.junit.jupiter.MockitoExtension;
20+
import org.mockserver.integration.ClientAndServer;
21+
import org.mockserver.junit.jupiter.MockServerExtension;
22+
import org.mockserver.junit.jupiter.MockServerSettings;
23+
import org.mockserver.logging.MockServerLogger;
24+
import org.mockserver.socket.PortFactory;
25+
import org.mockserver.socket.tls.KeyStoreFactory;
26+
27+
@ExtendWith(MockServerExtension.class)
28+
@ExtendWith(MockitoExtension.class)
29+
@MockServerSettings(ports = {8787, 8888})
30+
class APIConnectorTest {
31+
private static ClientAndServer client;
32+
33+
@Mock PublicKeyStore keystore;
34+
35+
private APIConnector apiConnector;
36+
37+
@BeforeAll
38+
static void startMockServer() {
39+
// ensure all connection using HTTPS will use the SSL context defined by
40+
// MockServer to allow dynamically generated certificates to be accepted
41+
HttpsURLConnection.setDefaultSSLSocketFactory(
42+
new KeyStoreFactory(new MockServerLogger()).sslContext().getSocketFactory());
43+
client = ClientAndServer.startClientAndServer(PortFactory.findFreePort());
44+
}
45+
46+
@BeforeEach
47+
void setup() {
48+
client.reset();
49+
apiConnector =
50+
new APIConnector("ID", "secret", "https://localhost:" + client.getPort() + "/", keystore);
51+
}
52+
53+
@Test
54+
void testLookupEmail() throws IOException {
55+
// GIVEN
56+
var email = "test@mail.box";
57+
var hash = DataUtils.byteArrayToHexString(CryptTool.hashEmail(email));
58+
var path = "/lookup/email_hash/" + hash;
59+
var threemaID = "the ID";
60+
client.when(request().withMethod("GET").withPath(path)).respond(response().withBody(threemaID));
61+
62+
assertThat(apiConnector.lookupEmail(email)).isEqualTo(threemaID);
63+
}
64+
65+
@Test
66+
void testLookupPhone() throws IOException {
67+
// GIVEN
68+
var number = "+49172989127128";
69+
var hash = DataUtils.byteArrayToHexString(CryptTool.hashPhoneNo(number));
70+
var path = "/lookup/phone_hash/" + hash;
71+
var threemaID = "the ID";
72+
client.when(request().withMethod("GET").withPath(path)).respond(response().withBody(threemaID));
73+
74+
assertThat(apiConnector.lookupPhone(number)).isEqualTo(threemaID);
75+
}
76+
77+
@ParameterizedTest
78+
@ValueSource(ints = {400, 401, 404, 500})
79+
void testLookupPhoneErrors(int httpStatusCode) throws IOException {
80+
// GIVEN
81+
var number = "+49172989127128";
82+
var hash = DataUtils.byteArrayToHexString(CryptTool.hashPhoneNo(number));
83+
var path = "/lookup/phone_hash/" + hash;
84+
client
85+
.when(request().withMethod("GET").withPath(path))
86+
.respond(response().withStatusCode(httpStatusCode));
87+
88+
assertThatIOException().isThrownBy(() -> apiConnector.lookupPhone(number));
89+
}
90+
}

0 commit comments

Comments
 (0)