From e21f8ab763d5fcc4392b12af37d0e9b0b6eea2dd Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk Date: Sun, 10 Sep 2023 13:11:08 +0200 Subject: [PATCH] passwordless http client unit tests completed --- pom.xml | 17 ++ .../PasswordlessHttpClientTest.java | 281 ++++++++++++++++-- .../passwordless/factory/DataFactory.java | 18 ++ .../passwordless/model/TestResponse.java | 3 +- src/test/resources/logback.xml | 13 + 5 files changed, 306 insertions(+), 26 deletions(-) create mode 100644 src/test/resources/logback.xml diff --git a/pom.xml b/pom.xml index bcba8fe..626cfd7 100644 --- a/pom.xml +++ b/pom.xml @@ -57,6 +57,12 @@ jackson-datatype-jdk8 2.15.2 + + org.slf4j + slf4j-api + 1.7.33 + + org.projectlombok lombok @@ -92,6 +98,12 @@ 2.35.1 test + + ch.qos.logback + logback-classic + 1.2.12 + test + @@ -154,6 +166,11 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + diff --git a/src/test/java/com/bitwarden/passwordless/PasswordlessHttpClientTest.java b/src/test/java/com/bitwarden/passwordless/PasswordlessHttpClientTest.java index d5844f0..e61ce8b 100644 --- a/src/test/java/com/bitwarden/passwordless/PasswordlessHttpClientTest.java +++ b/src/test/java/com/bitwarden/passwordless/PasswordlessHttpClientTest.java @@ -1,39 +1,48 @@ package com.bitwarden.passwordless; +import com.bitwarden.passwordless.error.PasswordlessApiException; +import com.bitwarden.passwordless.error.PasswordlessProblemDetails; import com.bitwarden.passwordless.factory.DataFactory; import com.bitwarden.passwordless.model.TestRequest; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.bitwarden.passwordless.model.TestResponse; +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.http.Body; +import com.github.tomakehurst.wiremock.http.ContentTypeHeader; +import com.github.tomakehurst.wiremock.http.MimeType; import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.ProtocolException; import org.apache.hc.core5.http.io.entity.EntityUtils; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import java.io.IOException; +import java.net.ServerSocket; import java.net.URI; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; import java.util.Collections; -import java.util.HashMap; import java.util.Map; import java.util.TreeMap; +import static com.github.tomakehurst.wiremock.client.WireMock.*; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.*; class PasswordlessHttpClientTest { @RegisterExtension static WireMockExtension wireMock = WireMockExtension.newInstance() - .options(wireMockConfig() -// .globalTemplating(true) - .dynamicPort()) + .options(wireMockConfig().dynamicPort()) .failOnUnmatchedRequests(true) .build(); @@ -43,8 +52,6 @@ class PasswordlessHttpClientTest { @BeforeEach void setUp() { passwordlessOptions = DataFactory.passwordlessOptions(wireMock.baseUrl()); - passwordlessHttpClient = PasswordlessClientBuilder.create(passwordlessOptions) - .buildPasswordlessHttpClient(); } @AfterEach @@ -57,8 +64,6 @@ void tearDown() throws IOException { @Test void createPostRequest_invalidApiUrl_IOException() throws IOException { - passwordlessHttpClient.close(); - PasswordlessOptions passwordlessOptions = DataFactory.passwordlessOptions("http://localhost^:8080"); passwordlessHttpClient = PasswordlessClientBuilder.create(passwordlessOptions) .buildPasswordlessHttpClient(); @@ -72,6 +77,8 @@ void createPostRequest_invalidApiUrl_IOException() throws IOException { @Test void createPostRequest_nullPayload_NPE() { + initPasswordlessHttpClient(); + assertThatThrownBy(() -> passwordlessHttpClient.createPostRequest("login", null)) .isInstanceOf(NullPointerException.class) .hasMessage("POST payload is null"); @@ -79,6 +86,8 @@ void createPostRequest_nullPayload_NPE() { @Test void createPostRequest_payload_noError() throws IOException, ProtocolException, URISyntaxException { + initPasswordlessHttpClient(); + TestRequest testRequest = DataFactory.testRequest(); String testRequestJson = passwordlessHttpClient.objectMapper.writeValueAsString(testRequest); @@ -88,19 +97,20 @@ void createPostRequest_payload_noError() throws IOException, ProtocolException, @Test void createGetRequest_invalidApiUrl_IOException() throws IOException { - passwordlessHttpClient.close(); - PasswordlessOptions passwordlessOptions = DataFactory.passwordlessOptions("http://localhost^:8080"); passwordlessHttpClient = PasswordlessClientBuilder.create(passwordlessOptions) .buildPasswordlessHttpClient(); - assertThatThrownBy(() -> passwordlessHttpClient.createGetRequest("login", Collections.singletonMap("userId", "testUser"))) + assertThatThrownBy(() -> passwordlessHttpClient.createGetRequest("login", + Collections.singletonMap("userId", "testUser"))) .isInstanceOf(IOException.class) .hasMessageContaining("http://localhost^:8080"); } @Test void createGetRequest_queryParametersNull_noError() throws IOException, ProtocolException, URISyntaxException { + initPasswordlessHttpClient(); + ClassicHttpRequest request = passwordlessHttpClient.createGetRequest("login", null); validateRequest(request, "GET", "/login", null); @@ -108,6 +118,8 @@ void createGetRequest_queryParametersNull_noError() throws IOException, Protocol @Test void createGetRequest_queryParametersEmpty_noError() throws IOException, ProtocolException, URISyntaxException { + initPasswordlessHttpClient(); + ClassicHttpRequest request = passwordlessHttpClient.createGetRequest("login", Collections.emptyMap()); validateRequest(request, "GET", "/login", null); @@ -115,6 +127,8 @@ void createGetRequest_queryParametersEmpty_noError() throws IOException, Protoco @Test void createGetRequest_twoQueryParameters_noError() throws IOException, ProtocolException, URISyntaxException { + initPasswordlessHttpClient(); + Map queryParameters = new TreeMap<>(); queryParameters.put("page", "1"); queryParameters.put("userId", "testUser"); @@ -125,39 +139,249 @@ void createGetRequest_twoQueryParameters_noError() throws IOException, ProtocolE } @Test - void sendRequest_unreachableApiUrl_IOException() { + void sendRequest_unreachableApiUrl_IOException() throws IOException { + int unreachablePort = 0; + try (ServerSocket serverSocket = new ServerSocket(0)) { + assertThat(serverSocket).isNotNull(); + assertThat(serverSocket.getLocalPort()).isGreaterThan(0); + unreachablePort = serverSocket.getLocalPort(); + } catch (IOException e) { + fail("Port is not available"); + } + + String apiUrl = "http://localhost:" + unreachablePort; + PasswordlessOptions passwordlessOptions = DataFactory.passwordlessOptions(apiUrl); + passwordlessHttpClient = PasswordlessClientBuilder.create(passwordlessOptions) + .buildPasswordlessHttpClient(); + TestRequest testRequest = DataFactory.testRequest(); + + ClassicHttpRequest request = passwordlessHttpClient.createPostRequest("login", testRequest); + + assertThatThrownBy(() -> passwordlessHttpClient.sendRequest(request, new TypeReference() { + })) + .isInstanceOf(IOException.class) + .hasMessageContaining(apiUrl) + .hasMessageContaining("Connection refused"); + } + + @Test + void sendRequest_notFoundPathResponseNoPayload_PasswordlessApiException() throws IOException { + initPasswordlessHttpClient(); + + wireMock.stubFor(post(urlEqualTo("/login")) + .willReturn(WireMock.notFound())); + + TestRequest testRequest = DataFactory.testRequest(); + + ClassicHttpRequest request = passwordlessHttpClient.createPostRequest("login", testRequest); + + PasswordlessApiException passwordlessApiException = catchThrowableOfType( + () -> passwordlessHttpClient.sendRequest(request, new TypeReference() { + }), PasswordlessApiException.class); + + assertThat(passwordlessApiException).isInstanceOf(PasswordlessApiException.class); + PasswordlessProblemDetails problemDetails = passwordlessApiException.getDetails(); + assertThat(problemDetails).isNotNull(); + assertThat(problemDetails.getType()).isEqualTo("https://docs.passwordless.dev/guide/errors.html"); + assertThat(problemDetails.getTitle()).isEqualTo("Unexpected error"); + assertThat(problemDetails.getStatus()).isEqualTo(404); + assertThat(problemDetails.getDetail()).isNull(); + + verifyPost(testRequest); } @Test - void sendRequest_notFoundPath_PasswordlessApiException() { + void sendRequest_notFoundPathResponseWithPayload_PasswordlessApiException() throws IOException { + initPasswordlessHttpClient(); + + TestResponse testResponse = DataFactory.testResponse(); + String testResponseJson = passwordlessHttpClient.objectMapper.writeValueAsString(testResponse); + + wireMock.stubFor(post(urlEqualTo("/login")) + .willReturn(WireMock.notFound() + .withResponseBody(Body.fromJsonBytes(testResponseJson.getBytes(StandardCharsets.UTF_8))))); + + TestRequest testRequest = DataFactory.testRequest(); + + ClassicHttpRequest request = passwordlessHttpClient.createPostRequest("login", testRequest); + + PasswordlessApiException passwordlessApiException = catchThrowableOfType( + () -> passwordlessHttpClient.sendRequest(request, new TypeReference() { + }), PasswordlessApiException.class); + + assertThat(passwordlessApiException).isInstanceOf(PasswordlessApiException.class); + PasswordlessProblemDetails problemDetails = passwordlessApiException.getDetails(); + assertThat(problemDetails).isNotNull(); + assertThat(problemDetails.getType()).isEqualTo("https://docs.passwordless.dev/guide/errors.html"); + assertThat(problemDetails.getTitle()).isEqualTo("Unexpected error"); + assertThat(problemDetails.getStatus()).isEqualTo(404); + assertThat(problemDetails.getDetail()).isEqualTo(testResponseJson); + + verifyPost(testRequest); } @Test - void sendRequest_notFoundPathWithResponse_PasswordlessApiException() { + void sendRequest_errorResponseProblemJson_PasswordlessApiException() throws IOException { + initPasswordlessHttpClient(); + + PasswordlessProblemDetails problemDetailsInvalidToken = DataFactory.passwordlessProblemDetailsInvalidToken(); + String problemDetailsInvalidTokenJson = passwordlessHttpClient.objectMapper.writeValueAsString(problemDetailsInvalidToken); + + wireMock.stubFor(post(urlEqualTo("/login")) + .willReturn(WireMock.badRequest() + .withHeader("Content-Type", ContentType.APPLICATION_PROBLEM_JSON.getMimeType()) + .withResponseBody(Body.fromJsonBytes(problemDetailsInvalidTokenJson.getBytes(StandardCharsets.UTF_8))))); + + TestRequest testRequest = DataFactory.testRequest(); + + ClassicHttpRequest request = passwordlessHttpClient.createPostRequest("login", testRequest); + + PasswordlessApiException passwordlessApiException = catchThrowableOfType( + () -> passwordlessHttpClient.sendRequest(request, new TypeReference() { + }), PasswordlessApiException.class); + + assertThat(passwordlessApiException).isInstanceOf(PasswordlessApiException.class); + PasswordlessProblemDetails problemDetails = passwordlessApiException.getDetails(); + assertThat(problemDetails).isEqualTo(problemDetailsInvalidToken); + + verifyPost(testRequest); } @Test - void sendRequest_errorResponseProblemJson_PasswordlessApiException() { + void sendRequest_okResponseNotJson_IOException() throws IOException { + initPasswordlessHttpClient(); + + String xml = "Test Node"; + wireMock.stubFor(post(urlEqualTo("/login")) + .willReturn(WireMock.ok() + .withResponseBody(Body.ofBinaryOrText(xml.getBytes(StandardCharsets.UTF_8), + new ContentTypeHeader(MimeType.XML.toString()))))); + + TestRequest testRequest = DataFactory.testRequest(); + + ClassicHttpRequest request = passwordlessHttpClient.createPostRequest("login", testRequest); + + IOException exception = catchThrowableOfType( + () -> passwordlessHttpClient.sendRequest(request, new TypeReference() { + }), IOException.class); + + assertThat(exception).isInstanceOf(IOException.class).isInstanceOf(JacksonException.class); + + verifyPost(testRequest); } @Test - void sendRequest_okResponseNotJson_IOException() { + void sendRequest_getRequestOkResponseWithPayload_noError() throws PasswordlessApiException, IOException { + initPasswordlessHttpClient(); + + TestResponse testResponse = DataFactory.testResponse(); + String testResponseJson = passwordlessHttpClient.objectMapper.writeValueAsString(testResponse); + + wireMock.stubFor(get(urlEqualTo("/login")) + .willReturn(WireMock.ok() + .withResponseBody(Body.fromJsonBytes(testResponseJson.getBytes(StandardCharsets.UTF_8))))); + + ClassicHttpRequest request = passwordlessHttpClient.createGetRequest("login", null); + + TestResponse response = passwordlessHttpClient.sendRequest(request, new TypeReference() { + }); + + assertThat(response).isEqualTo(testResponse); + + wireMock.verify(1, getRequestedFor(urlEqualTo("/login")) + .withHeader("ApiSecret", equalTo(passwordlessOptions.getApiPrivateKey()))); } @Test - void sendRequest_okResponseNoPayload_noError() { + void sendRequest_postRequestOkResponseWithPayload_noError() throws PasswordlessApiException, IOException { + initPasswordlessHttpClient(); + + TestResponse testResponse = DataFactory.testResponse(); + String testResponseJson = passwordlessHttpClient.objectMapper.writeValueAsString(testResponse); + + wireMock.stubFor(post(urlEqualTo("/login")) + .willReturn(WireMock.ok() + .withResponseBody(Body.fromJsonBytes(testResponseJson.getBytes(StandardCharsets.UTF_8))))); + + TestRequest testRequest = DataFactory.testRequest(); + + ClassicHttpRequest request = passwordlessHttpClient.createPostRequest("login", testRequest); + + TestResponse response = passwordlessHttpClient.sendRequest(request, new TypeReference() { + }); + + assertThat(response).isEqualTo(testResponse); + + verifyPost(testRequest); } @Test - void sendRequest_okResponseJson_noError() { + void sendRequest_postRequestOkResponseNoPayload_noError() throws IOException, PasswordlessApiException { + initPasswordlessHttpClient(); + + wireMock.stubFor(post(urlEqualTo("/login")) + .willReturn(WireMock.ok())); + + TestRequest testRequest = DataFactory.testRequest(); + + ClassicHttpRequest request = passwordlessHttpClient.createPostRequest("login", testRequest); + + Object response = passwordlessHttpClient.sendRequest(request, new TypeReference() { + }); + + assertThat(response).isNull(); + + verifyPost(testRequest); } @Test - void close_userProvidedHttpClient_httpClientNotClosed() { + void close_userProvidedHttpClient_httpClientNotClosed() throws IOException, PasswordlessApiException { + try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { + passwordlessHttpClient = PasswordlessClientBuilder.create(passwordlessOptions) + .httpClient(httpClient) + .buildPasswordlessHttpClient(); + + passwordlessHttpClient.close(); + + // Apache HttpClient once closed cannot be re-used, verify by sending request + wireMock.stubFor(post(urlEqualTo("/login")) + .willReturn(WireMock.ok())); + + TestRequest testRequest = DataFactory.testRequest(); + + ClassicHttpRequest request = passwordlessHttpClient.createPostRequest("login", testRequest); + + passwordlessHttpClient.sendRequest(request, new TypeReference() { + }); + + verifyPost(testRequest); + } } @Test - void close_defaultHttpClient_httpClientClosed() { + void close_defaultHttpClient_httpClientClosed() throws IOException, PasswordlessApiException { + initPasswordlessHttpClient(); + + passwordlessHttpClient.close(); + + wireMock.stubFor(post(urlEqualTo("/login")) + .willReturn(WireMock.ok())); + + TestRequest testRequest = DataFactory.testRequest(); + + ClassicHttpRequest request = passwordlessHttpClient.createPostRequest("login", testRequest); + + Exception exception = catchException(() -> passwordlessHttpClient.sendRequest(request, new TypeReference() { + })); + + assertThat(exception).isNotNull(); + + wireMock.verify(0, anyRequestedFor(anyUrl())); + } + + private void initPasswordlessHttpClient() { + passwordlessHttpClient = PasswordlessClientBuilder.create(passwordlessOptions) + .buildPasswordlessHttpClient(); } private void validateRequest(ClassicHttpRequest request, String method, String path, String payloadJson) @@ -177,4 +401,11 @@ private void validateRequest(ClassicHttpRequest request, String method, String p assertThat(request.getEntity()).isNull(); } } + + private void verifyPost(TestRequest testRequest) throws JsonProcessingException { + wireMock.verify(1, postRequestedFor(urlEqualTo("/login")) + .withHeader("Content-Type", equalTo(ContentType.APPLICATION_JSON.toString())) + .withHeader("ApiSecret", equalTo(passwordlessOptions.getApiPrivateKey())) + .withRequestBody(equalToJson(passwordlessHttpClient.objectMapper.writeValueAsString(testRequest)))); + } } diff --git a/src/test/java/com/bitwarden/passwordless/factory/DataFactory.java b/src/test/java/com/bitwarden/passwordless/factory/DataFactory.java index eedcafd..0d6576f 100644 --- a/src/test/java/com/bitwarden/passwordless/factory/DataFactory.java +++ b/src/test/java/com/bitwarden/passwordless/factory/DataFactory.java @@ -1,7 +1,9 @@ package com.bitwarden.passwordless.factory; import com.bitwarden.passwordless.PasswordlessOptions; +import com.bitwarden.passwordless.error.PasswordlessProblemDetails; import com.bitwarden.passwordless.model.TestRequest; +import com.bitwarden.passwordless.model.TestResponse; import lombok.experimental.UtilityClass; @UtilityClass @@ -19,4 +21,20 @@ public TestRequest testRequest() { .build(); } + public TestResponse testResponse() { + return TestResponse.builder() + .field1(456) + .build(); + } + + public PasswordlessProblemDetails passwordlessProblemDetailsInvalidToken() { + return PasswordlessProblemDetails.builder() + .type("https://docs.passwordless.dev/guide/errors.html#invalid_token") + .title("The token you sent was not correct. The token used for this endpoint should start with 'verify_'. Make sure you are not sending the wrong value. The value you sent started with 'x'") + .status(400) + .errorCode("invalid_token") + .detail("Makes sure your request contains the expected a value for the token.") + .instance("/login") + .build(); + } } diff --git a/src/test/java/com/bitwarden/passwordless/model/TestResponse.java b/src/test/java/com/bitwarden/passwordless/model/TestResponse.java index 09c0a02..454d38d 100644 --- a/src/test/java/com/bitwarden/passwordless/model/TestResponse.java +++ b/src/test/java/com/bitwarden/passwordless/model/TestResponse.java @@ -10,6 +10,7 @@ @AllArgsConstructor @Builder public class TestResponse { + @NonNull Integer field1; - Instant field4; + Instant field2; } diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml new file mode 100644 index 0000000..232c953 --- /dev/null +++ b/src/test/resources/logback.xml @@ -0,0 +1,13 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + +