Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package dev.eidentification.bankid.exceptions;

import org.jspecify.annotations.Nullable;

import java.net.http.HttpResponse;

/**
Expand All @@ -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;
}
Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<R extends Response, E extends ErrorResponse> implements HttpResponse.BodyHandler<R> {
Expand Down Expand Up @@ -41,6 +42,16 @@ public HttpResponse.BodySubscriber<R> apply(final HttpResponse.ResponseInfo resp
return HttpResponse.BodySubscribers.mapping(upstream, inputStream -> inputStreamTo(responseClazz, responseInfo, inputStream));
}

final Optional<String> 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));
});
Expand All @@ -58,4 +69,13 @@ private <T> T inputStreamTo(final Class<T> 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);
}
}
}
33 changes: 33 additions & 0 deletions src/main/resources/ca.prod.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-----BEGIN CERTIFICATE-----
MIIFvjCCA6agAwIBAgIITyTh/u1bExowDQYJKoZIhvcNAQENBQAwYjEkMCIGA1UE
CgwbRmluYW5zaWVsbCBJRC1UZWtuaWsgQklEIEFCMRowGAYDVQQLDBFJbmZyYXN0
cnVjdHVyZSBDQTEeMBwGA1UEAwwVQmFua0lEIFNTTCBSb290IENBIHYxMB4XDTEx
MTIwNzEyMzQwN1oXDTM0MTIzMTEyMzQwN1owYjEkMCIGA1UECgwbRmluYW5zaWVs
bCBJRC1UZWtuaWsgQklEIEFCMRowGAYDVQQLDBFJbmZyYXN0cnVjdHVyZSBDQTEe
MBwGA1UEAwwVQmFua0lEIFNTTCBSb290IENBIHYxMIICIjANBgkqhkiG9w0BAQEF
AAOCAg8AMIICCgKCAgEAwVA4snZiSFI3r64LvYu4mOsI42A9aLKEQGq4IZo257iq
vPH82SMvgBJgE52kCx7gQMmZ7iSm39CEA19hlILh8JEJNTyJNxMxVDN6cfJP1jMH
JeTES1TmVbWUqGyLpyT8LCJhC9Vq4W3t/O1svGJNOUQIQL4eAHSvWTVoalxzomJh
On97ENjXAt4BLb6sHfVBvmB5ReK0UfwpNACFM1RN8btEaDdWC4PfA72yzV3wK/cY
5h2k1RM1s19PjoxnpJqrmn4qZmP4tN/nk2d7c4FErJAP0pnNsll1+JfkdMfiPD35
+qcclpspzP2LpauQVyPbO21Nh+EPtr7+Iic2tkgz0g1kK0IL/foFrJ0Ievyr3Drm
2uRnA0esZ45GOmZhE22mycEX9l7w9jrdsKtqs7N/T46hil4xBiGblXkqKNG6TvAR
k6XqOp3RtUvGGaKZnGllsgTvP38/nrSMlszNojrlbDnm16GGoRTQnwr8l+Yvbz/e
v/e6wVFDjb52ZB0Z/KTfjXOl5cAJ7OCbODMWf8Na56OTlIkrk5NyU/uGzJFUQSvG
dLHUipJ/sTZCbqNSZUwboI0oQNO/Ygez2J6zgWXGpDWiN4LGLDmBhB3T8CMQu9J/
BcFvgjnUyhyim35kDpjVPC8nrSir5OkaYgGdYWdDuv1456lFNPNNQcdZdt5fcmMC
AwEAAaN4MHYwHQYDVR0OBBYEFPgqsux5RtcrIhAVeuLBSgBuRDFVMA8GA1UdEwEB
/wQFMAMBAf8wHwYDVR0jBBgwFoAU+Cqy7HlG1ysiEBV64sFKAG5EMVUwEwYDVR0g
BAwwCjAIBgYqhXBOAQQwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBDQUAA4IC
AQAJOjUOS2GJPNrrrqf539aN1/EbUj5ZVRjG4wzVtX5yVqPGcRZjUQlNTcfOpwPo
czKBnNX2OMF+Qm94bb+xXc/08AERqJJ3FPKu8oDNeK+Rv1X4nh95J4RHZcvl4AGh
ECmGMyhyCea0qZBFBsBqQR7oC9afYOxsSovaPqX31QMLULWUYoBKWWHLVVIoHjAm
GtAzMkLwe0/lrVyApr9iyXWhVr+qYGmFGw1+rwmvDmmSLWNWawYgH4NYxTf8z5hB
iDOdAgilvyiAF8Yl0kCKUB2fAPhRNYlEcN+UP/KL24h/pB+hZ9mvR0tM6nW3HVZa
DrvRz4VihZ8vRi3fYnOAkNE6kZdrrdO7LdBc9yYkfQdTcy0N+Aw7q4TkQ8npomrV
mTKaPhtGhA7VICyRNBVcvyoxr+CY7aRQyHn/C7n/jRsQYxs7uc+msq6jRS4HPK8o
lnF9usWZX6KY+8mweJiTE4uN4ZUUBUtt8WcXXDiK/bxEG2amjPcZ/b4LXwGCJb+a
NWP4+iY6kBKrMANs01pLvtVjUS9RtRrY3cNEOhmKhO0qJSDXhsTcVtpbDr37UTSq
QVw83dReiARPwGdURmmkaheH6z4k6qEUSXuFch0w53UAc+1aBXR1bgyFqMdy7Yxi
b2AYu7wnrHioDWqP6DTkUSUeMB/zqWPM/qx6QNNOcaOcjA==
-----END CERTIFICATE-----
Binary file modified src/main/resources/test.p12
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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<SignResponse, ErrorResponse> 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<SignResponse> 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<SignResponse, ErrorResponse> 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<SignResponse> 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<SignResponse, ErrorResponse> handler =
new JsonBodyHandler<>(SignResponse.class, ErrorResponse.class, objectMapper);
final HttpResponse.ResponseInfo info = responseInfo(500, null);
final String body = "<html>Server error</html>";

final HttpResponse.BodySubscriber<SignResponse> subscriber = handler.apply(info);

Assertions.assertThrows(BankIdApiUnexpectedResponseException.class, () -> complete(feed(subscriber, body)));
}

@Test
void givenNon200AndNonJsonContentType_whenApply_thenThrowsUnexpectedResponse() {
final JsonBodyHandler<SignResponse, ErrorResponse> handler =
new JsonBodyHandler<>(SignResponse.class, ErrorResponse.class, objectMapper);
final HttpResponse.ResponseInfo info = responseInfo(404, "text/plain");
final String body = "Not found";

final HttpResponse.BodySubscriber<SignResponse> subscriber = handler.apply(info);

Assertions.assertThrows(BankIdApiUnexpectedResponseException.class, () -> complete(feed(subscriber, body)));
}

@Test
void givenNon200AndJsonContentTypeWithValidJson_whenApply_thenThrowsApiError() {
final JsonBodyHandler<SignResponse, ErrorResponse> 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<SignResponse> subscriber = handler.apply(info);

Assertions.assertThrows(BankIdApiErrorException.class, () -> complete(feed(subscriber, errorJson)));
}

@Test
void givenNon200AndJsonContentTypeWithInvalidJson_whenApply_thenThrowsUnexpectedResponse() {
final JsonBodyHandler<SignResponse, ErrorResponse> 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<SignResponse> 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<String, List<String>> 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> T complete(final CompletableFuture<T> 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 <T> CompletableFuture<T> feed(final HttpResponse.BodySubscriber<T> 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
}
}
}