diff --git a/src/main/java/dev/eidentification/bankid/exceptions/BankIdApiUnexpectedResponseException.java b/src/main/java/dev/eidentification/bankid/exceptions/BankIdApiUnexpectedResponseException.java index cadb4b0..c3d7af0 100644 --- a/src/main/java/dev/eidentification/bankid/exceptions/BankIdApiUnexpectedResponseException.java +++ b/src/main/java/dev/eidentification/bankid/exceptions/BankIdApiUnexpectedResponseException.java @@ -1,5 +1,7 @@ package dev.eidentification.bankid.exceptions; +import org.jspecify.annotations.Nullable; + import java.net.http.HttpResponse; /** @@ -18,8 +20,14 @@ public final class BankIdApiUnexpectedResponseException extends BankIdException private final HttpResponse.ResponseInfo responseInfo; private final String responseBody; - private BankIdApiUnexpectedResponseException(final HttpResponse.ResponseInfo responseInfo, final String responseBody, final Throwable cause) { - super(cause); + private BankIdApiUnexpectedResponseException(final HttpResponse.ResponseInfo responseInfo, final String responseBody) { + super("BankId API returned an unexpected response. Body: " + responseBody); + this.responseInfo = responseInfo; + this.responseBody = responseBody; + } + + private BankIdApiUnexpectedResponseException(final HttpResponse.ResponseInfo responseInfo, final String responseBody, @Nullable final Throwable cause) { + super("BankId API returned an unexpected response. Body: " + responseBody, cause); this.responseInfo = responseInfo; this.responseBody = responseBody; } @@ -32,10 +40,22 @@ private BankIdApiUnexpectedResponseException(final HttpResponse.ResponseInfo res * @param cause The cause of the exception. * @return A new instance of BankIdApiUnexpectedResponseException. */ - public static BankIdApiUnexpectedResponseException of(final HttpResponse.ResponseInfo responseInfo, final String responseBody, final Throwable cause) { + public static BankIdApiUnexpectedResponseException of(final HttpResponse.ResponseInfo responseInfo, final String responseBody, + @Nullable final Throwable cause) { return new BankIdApiUnexpectedResponseException(responseInfo, responseBody, cause); } + /** + * Creates a new instance of BankIdApiUnexpectedResponseException. + * + * @param responseInfo The response information associated with the exception. + * @param responseBody The response body associated with the exception. + * @return A new instance of BankIdApiUnexpectedResponseException. + */ + public static BankIdApiUnexpectedResponseException of(final HttpResponse.ResponseInfo responseInfo, final String responseBody) { + return new BankIdApiUnexpectedResponseException(responseInfo, responseBody); + } + /** * Retrieves the response information associated with an exception thrown during interaction with the BankId system. * diff --git a/src/main/java/dev/eidentification/bankid/internal/http/JsonBodyHandler.java b/src/main/java/dev/eidentification/bankid/internal/http/JsonBodyHandler.java index 0e66c08..03e0fe9 100644 --- a/src/main/java/dev/eidentification/bankid/internal/http/JsonBodyHandler.java +++ b/src/main/java/dev/eidentification/bankid/internal/http/JsonBodyHandler.java @@ -1,15 +1,16 @@ package dev.eidentification.bankid.internal.http; import com.fasterxml.jackson.databind.ObjectMapper; -import dev.eidentification.bankid.internal.annotations.Internal; import dev.eidentification.bankid.client.response.ErrorResponse; import dev.eidentification.bankid.client.response.Response; import dev.eidentification.bankid.exceptions.BankIdApiErrorException; import dev.eidentification.bankid.exceptions.BankIdApiUnexpectedResponseException; +import dev.eidentification.bankid.internal.annotations.Internal; import java.io.InputStream; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.util.Optional; @Internal public class JsonBodyHandler implements HttpResponse.BodyHandler { @@ -41,6 +42,16 @@ public HttpResponse.BodySubscriber apply(final HttpResponse.ResponseInfo resp return HttpResponse.BodySubscribers.mapping(upstream, inputStream -> inputStreamTo(responseClazz, responseInfo, inputStream)); } + final Optional optionalContentType = responseInfo.headers() + .firstValue("Content-Type") + .map(String::toLowerCase); + + if (optionalContentType.isEmpty() || !optionalContentType.get().contains("application/json")) { + return HttpResponse.BodySubscribers.mapping(upstream, inputStream -> { + throw BankIdApiUnexpectedResponseException.of(responseInfo, inputStreamToString(responseInfo, inputStream)); + }); + } + return HttpResponse.BodySubscribers.mapping(upstream, inputStream -> { throw BankIdApiErrorException.of(inputStreamTo(errorResponseClazz, responseInfo, inputStream)); }); @@ -58,4 +69,13 @@ private T inputStreamTo(final Class targetType, final HttpResponse.Respon } } + private static String inputStreamToString(final HttpResponse.ResponseInfo responseInfo, final InputStream inputStream) { + String body = ""; + + try (final InputStream stream = inputStream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (final Exception e) { + throw BankIdApiUnexpectedResponseException.of(responseInfo, body, e); + } + } } diff --git a/src/main/resources/ca.prod.crt b/src/main/resources/ca.prod.crt new file mode 100644 index 0000000..291b5d7 --- /dev/null +++ b/src/main/resources/ca.prod.crt @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- \ No newline at end of file diff --git a/src/main/resources/test.p12 b/src/main/resources/test.p12 index f678de8..706092f 100644 Binary files a/src/main/resources/test.p12 and b/src/main/resources/test.p12 differ diff --git a/src/test/java/dev/eidentification/bankid/internal/http/JsonBodyHandlerUnitTest.java b/src/test/java/dev/eidentification/bankid/internal/http/JsonBodyHandlerUnitTest.java new file mode 100644 index 0000000..ed42d81 --- /dev/null +++ b/src/test/java/dev/eidentification/bankid/internal/http/JsonBodyHandlerUnitTest.java @@ -0,0 +1,176 @@ +package dev.eidentification.bankid.internal.http; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.eidentification.bankid.UnitTest; +import dev.eidentification.bankid.client.response.ErrorResponse; +import dev.eidentification.bankid.client.response.SignResponse; +import dev.eidentification.bankid.exceptions.BankIdApiErrorException; +import dev.eidentification.bankid.exceptions.BankIdApiUnexpectedResponseException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.fail; + +class JsonBodyHandlerUnitTest extends UnitTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void givenStatus200AndValidJson_whenApply_thenParsesResponse() { + final JsonBodyHandler handler = + new JsonBodyHandler<>(SignResponse.class, ErrorResponse.class, objectMapper); + + final HttpResponse.ResponseInfo info = responseInfo(200, "application/json; charset=utf-8"); + + final String json = """ + { + "orderRef":"131daac9-16c6-4618-beb0-365768f37288", + "autoStartToken":"7c40b5c9-fa74-49cf-b98c-bfe651f9a7c6" + }"""; + + final HttpResponse.BodySubscriber subscriber = handler.apply(info); + final SignResponse result = complete(feed(subscriber, json)); + + Assertions.assertNotNull(result); + Assertions.assertEquals("131daac9-16c6-4618-beb0-365768f37288", result.getOrderRef()); + Assertions.assertEquals("7c40b5c9-fa74-49cf-b98c-bfe651f9a7c6", result.getAutoStartToken()); + } + + @Test + void givenStatus200AndInvalidJson_whenApply_thenThrowsUnexpectedResponse() { + final JsonBodyHandler handler = + new JsonBodyHandler<>(SignResponse.class, ErrorResponse.class, objectMapper); + final HttpResponse.ResponseInfo info = responseInfo(200, "application/json"); + final String invalidJson = "not-a-json"; + + final HttpResponse.BodySubscriber subscriber = handler.apply(info); + + final BankIdApiUnexpectedResponseException ex = Assertions.assertThrows( + BankIdApiUnexpectedResponseException.class, + () -> complete(feed(subscriber, invalidJson)) + ); + Assertions.assertTrue(ex.getMessage() != null && !ex.getMessage().isBlank()); + } + + @Test + void givenNon200AndMissingContentType_whenApply_thenThrowsUnexpectedResponse() { + final JsonBodyHandler handler = + new JsonBodyHandler<>(SignResponse.class, ErrorResponse.class, objectMapper); + final HttpResponse.ResponseInfo info = responseInfo(500, null); + final String body = "Server error"; + + final HttpResponse.BodySubscriber subscriber = handler.apply(info); + + Assertions.assertThrows(BankIdApiUnexpectedResponseException.class, () -> complete(feed(subscriber, body))); + } + + @Test + void givenNon200AndNonJsonContentType_whenApply_thenThrowsUnexpectedResponse() { + final JsonBodyHandler handler = + new JsonBodyHandler<>(SignResponse.class, ErrorResponse.class, objectMapper); + final HttpResponse.ResponseInfo info = responseInfo(404, "text/plain"); + final String body = "Not found"; + + final HttpResponse.BodySubscriber subscriber = handler.apply(info); + + Assertions.assertThrows(BankIdApiUnexpectedResponseException.class, () -> complete(feed(subscriber, body))); + } + + @Test + void givenNon200AndJsonContentTypeWithValidJson_whenApply_thenThrowsApiError() { + final JsonBodyHandler handler = + new JsonBodyHandler<>(SignResponse.class, ErrorResponse.class, objectMapper); + final HttpResponse.ResponseInfo info = responseInfo(400, "application/json"); + final String errorJson = "{\"errorCode\":\"BAD_REQUEST\",\"details\":\"Invalid input\"}"; + + final HttpResponse.BodySubscriber subscriber = handler.apply(info); + + Assertions.assertThrows(BankIdApiErrorException.class, () -> complete(feed(subscriber, errorJson))); + } + + @Test + void givenNon200AndJsonContentTypeWithInvalidJson_whenApply_thenThrowsUnexpectedResponse() { + final JsonBodyHandler handler = + new JsonBodyHandler<>(SignResponse.class, ErrorResponse.class, objectMapper); + final HttpResponse.ResponseInfo info = responseInfo(500, "Application/JSON; charset=UTF-8"); // case-insensitive + final String invalidJson = "{ this is broken }"; + + final HttpResponse.BodySubscriber subscriber = handler.apply(info); + + Assertions.assertThrows(BankIdApiUnexpectedResponseException.class, () -> complete(feed(subscriber, invalidJson))); + } + + private static HttpResponse.ResponseInfo responseInfo(final int status, final String contentType) { + final Map> headerMap = + contentType == null + ? Map.of() + : Map.of("Content-Type", List.of(contentType)); + + final HttpHeaders headers = HttpHeaders.of(headerMap, (k, v) -> true); + + return new HttpResponse.ResponseInfo() { + @Override + public int statusCode() { + return status; + } + + @Override + public HttpHeaders headers() { + return headers; + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + }; + } + + private static T complete(final CompletableFuture future) { + try { + return future.get(2, TimeUnit.SECONDS); + } catch (final TimeoutException e) { + fail("Timed out waiting for body subscriber to complete"); + return null; // unreachable + } catch (final ExecutionException e) { + if (e.getCause() instanceof RuntimeException re) { + throw re; + } + throw new CompletionException(e.getCause()); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + private static CompletableFuture feed(final HttpResponse.BodySubscriber subscriber, final String body) { + subscriber.onSubscribe(new NoopSubscription()); + subscriber.onNext(List.of(ByteBuffer.wrap(body.getBytes(StandardCharsets.UTF_8)))); + subscriber.onComplete(); + return subscriber.getBody().toCompletableFuture(); + } + + private static class NoopSubscription implements java.util.concurrent.Flow.Subscription { + @Override + public void request(final long n) { + // no-op + } + + @Override + public void cancel() { + // no-op + } + } +}