add IBAN+BIC validation

This commit is contained in:
Michael Hoennig 2022-10-05 08:04:44 +02:00
parent 398f15d5de
commit d98b8feaad
6 changed files with 178 additions and 20 deletions

View File

@ -60,6 +60,7 @@ dependencies {
implementation 'com.vladmihalcea:hibernate-types-55:2.19.2'
implementation 'org.openapitools:jackson-databind-nullable:0.2.3'
implementation 'org.modelmapper:modelmapper:3.1.0'
implementation 'org.iban4j:iban4j:3.2.3-RELEASE'
compileOnly 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'

View File

@ -2,6 +2,7 @@ package net.hostsharing.hsadminng.errors;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import org.iban4j.Iban4jException;
import org.springframework.core.NestedExceptionUtils;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
@ -54,6 +55,20 @@ public class RestResponseEntityExceptionHandler
return errorResponse(request, HttpStatus.BAD_REQUEST, message);
}
@ExceptionHandler(Iban4jException.class)
protected ResponseEntity<CustomErrorResponse> handleIbanAndBicExceptions(
final Throwable exc, final WebRequest request) {
final var message = firstLine(NestedExceptionUtils.getMostSpecificCause(exc).getMessage());
return errorResponse(request, HttpStatus.BAD_REQUEST, message);
}
@ExceptionHandler(Throwable.class)
protected ResponseEntity<CustomErrorResponse> handleOtherExceptions(
final Throwable exc, final WebRequest request) {
final var message = firstLine(NestedExceptionUtils.getMostSpecificCause(exc).getMessage());
return errorResponse(request, httpStatus(message).orElse(HttpStatus.INTERNAL_SERVER_ERROR), message);
}
private String userReadableEntityClassName(final String exceptionMessage) {
final var regex = "(net.hostsharing.hsadminng.[a-z0-9_.]*.[A-Za-z0-9_$]*Entity) ";
final var pattern = Pattern.compile(regex);
@ -61,7 +76,7 @@ public class RestResponseEntityExceptionHandler
if (matcher.find()) {
final var entityName = matcher.group(1);
final var entityClass = resolveClassOrNull(entityName);
if (entityClass != null ) {
if (entityClass != null) {
return (entityClass.isAnnotationPresent(DisplayName.class)
? exceptionMessage.replace(entityName, entityClass.getAnnotation(DisplayName.class).value())
: exceptionMessage.replace(entityName, entityClass.getSimpleName()))
@ -80,13 +95,6 @@ public class RestResponseEntityExceptionHandler
}
}
@ExceptionHandler(Throwable.class)
protected ResponseEntity<CustomErrorResponse> handleOtherExceptions(
final Throwable exc, final WebRequest request) {
final var message = firstLine(NestedExceptionUtils.getMostSpecificCause(exc).getMessage());
return errorResponse(request, httpStatus(message).orElse(HttpStatus.INTERNAL_SERVER_ERROR), message);
}
private Optional<HttpStatus> httpStatus(final String message) {
if (message.startsWith("ERROR: [")) {
for (HttpStatus status : HttpStatus.values()) {

View File

@ -5,6 +5,8 @@ import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeBankAccountsApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeBankAccountInsertResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeBankAccountResource;
import org.iban4j.BicUtil;
import org.iban4j.IbanUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@ -49,6 +51,9 @@ public class HsOfficeBankAccountController implements HsOfficeBankAccountsApi {
context.define(currentUser, assumedRoles);
IbanUtil.validate(body.getIban());
BicUtil.validate(body.getBic());
final var entityToSave = map(body, HsOfficeBankAccountEntity.class);
entityToSave.setUuid(UUID.randomUUID());

View File

@ -2,6 +2,8 @@ package net.hostsharing.hsadminng.errors;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaObjectRetrievalFailureException;
@ -9,7 +11,6 @@ import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.web.context.request.WebRequest;
import javax.persistence.EntityNotFoundException;
import java.util.NoSuchElementException;
import static org.assertj.core.api.Assertions.assertThat;
@ -147,6 +148,25 @@ class RestResponseEntityExceptionHandlerUnitTest {
assertThat(errorResponse.getBody().getMessage()).isEqualTo("some error message");
}
@ParameterizedTest
@ValueSource(classes = {
org.iban4j.InvalidCheckDigitException.class,
org.iban4j.IbanFormatException.class,
org.iban4j.BicFormatException.class })
void handlesIbanAndBicExceptions(final Class<? extends RuntimeException> givenExceptionClass)
throws Exception {
// given
final var givenException = givenExceptionClass.getConstructor(String.class).newInstance("given error message");
final var givenWebRequest = mock(WebRequest.class);
// when
final var errorResponse = exceptionHandler.handleIbanAndBicExceptions(givenException, givenWebRequest);
// then
assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400);
assertThat(errorResponse.getBody().getMessage()).isEqualTo("given error message");
}
@Test
void handleOtherExceptionsWithoutErrorCode() {
// given

View File

@ -115,7 +115,7 @@ class HsOfficeBankAccountControllerAcceptanceTest {
@Nested
@Accepts({ "bankaccount:C(Create)" })
class AddBankAccount {
class CreateBankAccount {
@Test
void globalAdmin_withoutAssumedRole_canAddBankAccount() {
@ -127,11 +127,11 @@ class HsOfficeBankAccountControllerAcceptanceTest {
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("""
{
"holder": "new test holder",
"iban": "DE88100900001234567892",
"bic": "BEVODEBB"
}
{
"holder": "new test holder",
"iban": "DE88100900001234567892",
"bic": "BEVODEBB"
}
""")
.port(port)
.when()
@ -195,8 +195,7 @@ class HsOfficeBankAccountControllerAcceptanceTest {
}
@Test
@Accepts({ "bankaccount:X(Access Control)" })
@Disabled("TODO: not implemented yet")
@Disabled("TODO: not implemented yet - also add Accepts annotation when done")
void bankaccountAdminUser_canGetRelatedBankAccount() {
context.define("superuser-alex@hostsharing.net");
final var givenBankAccountUuid = bankAccountRepo.findByOptionalHolderLike("first").get(0).getUuid();
@ -212,9 +211,9 @@ class HsOfficeBankAccountControllerAcceptanceTest {
.contentType("application/json")
.body("", lenientlyEquals("""
{
"label": "first bankaccount",
"emailAddresses": "bankaccount-admin@firstbankaccount.example.com",
"phoneNumbers": "+49 123 1234567"
"holder": "...",
"iban": "...",
"bic": "..."
}
""")); // @formatter:on
}

View File

@ -0,0 +1,125 @@
package net.hostsharing.hsadminng.hs.office.bankaccount;
import net.hostsharing.hsadminng.context.Context;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(HsOfficeBankAccountController.class)
class HsOfficeBankAccountControllerRestTest {
@Autowired
MockMvc mockMvc;
@MockBean
Context contextMock;
@MockBean
HsOfficeBankAccountRepository bankAccountRepo;
enum InvalidIbanTestCase {
TOO_SHORT("DE8810090000123456789", "[10090000123456789] length is 17, expected BBAN length is: 18"),
TOO_LONG("DE8810090000123456789123445", "[10090000123456789123445] length is 23, expected BBAN length is: 18"),
INVALID_CHARACTER("DE 8810090000123456789123445", "Iban's check digit should contain only digits."),
INVALID_CHECKSUM(
"DE88100900001234567893",
"[DE88100900001234567893] has invalid check digit: 88, expected check digit is: 61");
private final String givenIban;
private final String expectedIbanMessage;
InvalidIbanTestCase(final String givenIban, final String expectedErrorMessage) {
this.givenIban = givenIban;
this.expectedIbanMessage = expectedErrorMessage;
}
String givenIban() {
return givenIban;
}
String expectedErrorMessage() {
return expectedIbanMessage;
}
}
@ParameterizedTest
@EnumSource(InvalidIbanTestCase.class)
void invalidIbanBeRejected(final InvalidIbanTestCase testCase) throws Exception {
// when
mockMvc.perform(MockMvcRequestBuilders
.post("/api/hs/office/bankaccounts")
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"holder": "new test holder",
"iban": "%s",
"bic": "BEVODEBB"
}
""".formatted(testCase.givenIban()))
.accept(MediaType.APPLICATION_JSON))
// then
.andExpect(status().is4xxClientError())
.andExpect(jsonPath("status", is(400)))
.andExpect(jsonPath("error", is("Bad Request")))
.andExpect(jsonPath("message", is(testCase.expectedErrorMessage())));
}
enum InvalidBicTestCase {
TOO_SHORT("BEVODEB", "Bic length must be 8 or 11"),
TOO_LONG("BEVODEBBX", "Bic length must be 8 or 11"),
INVALID_CHARACTER("BEV-ODEB", "Bank code must contain only letters.");
private final String givenBic;
private final String expectedErrorMessage;
InvalidBicTestCase(final String givenBic, final String expectedErrorMessage) {
this.givenBic = givenBic;
this.expectedErrorMessage = expectedErrorMessage;
}
String givenIban() {
return givenBic;
}
String expectedErrorMessage() {
return expectedErrorMessage;
}
}
@ParameterizedTest
@EnumSource(InvalidBicTestCase.class)
void invalidBicBeRejected(final InvalidBicTestCase testCase) throws Exception {
// when
mockMvc.perform(MockMvcRequestBuilders
.post("/api/hs/office/bankaccounts")
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"holder": "new test holder",
"iban": "DE88100900001234567892",
"bic": "%s"
}
""".formatted(testCase.givenIban()))
.accept(MediaType.APPLICATION_JSON))
// then
.andExpect(status().is4xxClientError())
.andExpect(jsonPath("status", is(400)))
.andExpect(jsonPath("error", is("Bad Request")))
.andExpect(jsonPath("message", is(testCase.expectedErrorMessage())));
}
}