diff --git a/Verbeteringen.md b/Verbeteringen.md new file mode 100644 index 0000000..9541eac --- /dev/null +++ b/Verbeteringen.md @@ -0,0 +1,113 @@ +Plus punten: +Java 21 +Heel lief simpel klein applicatie en nog prima in orde te maken omdat het nog niet te laat is! +- weinig complexiteit + +------------------------------- +------------------------------- +Beoordeeld op basis van de ISO-25010 maintainability kwaliteiten: +* Modulariteit +* Herbruikbaarheid +* Analyseerbaarheid +* Aanpasbaarheid +* Testbaarheid + +Het volgende: + +* Niet Modulair: + - een enkele module + +* Moeilijk analyseerbaar + - teveel verschillende verantwoordelijkheden verzameld in een plek + - code duplicatie + - weinig tot geen documentatie (ook m.b.t. OpenAPI contract) + - geen duidelijke benamingen van classes op basis van verantwoordelijkheid + +* Moeilijk herbruikbaar: + - een enkele module met alle verantwoordelijkheden + - geen documentatie + - geen garantie op functionaliteit door ontbreken test coverage + +* Moeilijk aanpasbaar: + - elke aanpassing heeft grote gevolgen door grote stukken code + - weinig segmentatie + +* Moeilijk testbaar: + - erg weinig segementatie in verantwoordelijkheden + - teveel op een plek +------------------------------- +------------------------------- + +Te verbeteren +README.md mag veel meer informatie bevatten +* wat is het +* waar dient het voor +* hoe werkt het opzetten +* wie is ervoor verantwoordelijk +* etc + +Maven project +- versienummers kunnen beter als properties verntraal worden beheerd +- een mutatie test kan iets meer inzicht geven in hoe goed het getest wordt + +Documentatie: +* veel complexe cryptography vergt wel het een en ander aan uitleg + +Checkstyle & psot-bugs: +- veel warnings die suppressed worden kunnen net zo goed eruit of gefixed worden + +Testing +- 3 testen voor een hele applicatie is geen garantie dat de applicatie doet wat het moet doen +- test coverage is erg laag +- MUTATIE coverage is nog lager +- geen duidelijke beschrijving van tests en wat ze moeten doen +- enkel integratie testing en geen unit tests +- geen mocks +- niet makkelijk te testen vanwege + - onwenselijke instantiaties van on-mockbare objecten + - onduidelijkheid en verwevenheid in verantwoordelijkheden (teveel in een plek) +- integratie testen betekent simpelweg dat je bij elke aanpassing alle testen moet herzien + +Controllers: +- weinig validatie op input!!! +- doen teveel! +- @SneakyThrows probleem +- benaming +- moeilijk testbaar +- moeilijk leesbaar +- niet gedocumenteerd + +Services: +- doen teveel! houd het bij controlleren van input +- @SneakyThrows probleem +- benaming +- moeilijk testbaar +- moeilijk leesbaar +- niet gedocumenteerd +- duplicate code + +Utils: +- combineren van Byte[] wordt om meerdere plekken gedaan maar niet consistent gebruikmakend van deze util + +Configuratie: +- @Component vervangen met @Configuratioe +- zorg dat hier de validatie gedaan wordt i.p.v. constructors van classes die hier gebruik van maken +- test de configuratie + +Models: +- te veel suppress warnings +- geen eigen package +- hard coded values +- maak het netter met LOMBOK + +OpenAPI: +- documentatie ontbreekt +- maak het netter met LOMBOK + +application-properties: +- waarom is PRD logging altijd op DEBUG? + + + + + diff --git a/checkstyle.xml b/checkstyle.xml index c93c84c..5bde2a4 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -1,7 +1,7 @@ + "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" + "https://checkstyle.org/dtds/configuration_1_3.dtd"> - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 8ebf211..c0d8987 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,15 @@ Demo project for Spring Boot 21 + 3.1.0 + 4.8.6 + 1.79 + 3.6.0 + + 75 + 75 + 1.2.1 + 1.17.3 @@ -33,7 +42,7 @@ jakarta.validation jakarta.validation-api - 3.1.0 + ${jakarta.validation-api.version} org.springframework.boot @@ -48,13 +57,13 @@ com.github.spotbugs spotbugs-annotations - 4.8.6 + ${spotbugs-annotations.version} compile org.bouncycastle bcprov-jdk18on - 1.79 + ${bcprov-jdk18on.version} @@ -94,12 +103,21 @@ false true none - none + + + true + + + true + @lombok.Builder @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + + io.github.git-commit-id git-commit-id-maven-plugin @@ -117,7 +135,7 @@ com.github.spotbugs spotbugs-maven-plugin - 4.8.6.6 + ${spotbugs-annotations.version}.6 spotbugs-exclude.xml @@ -134,7 +152,7 @@ org.apache.maven.plugins maven-checkstyle-plugin - 3.6.0 + ${maven-checkstyle-plugin.version} checkstyle.xml true @@ -157,9 +175,6 @@ org.springframework.boot spring-boot-maven-plugin - - - org.projectlombok @@ -197,7 +212,7 @@ org.apache.maven.plugins maven-checkstyle-plugin - 3.6.0 + ${maven-checkstyle-plugin.version} @@ -208,6 +223,51 @@ + + + pitest + + + + pitest-maven + + ${pit.coverageThreshold} + ${pit.mutationThreshold} + + nl.ictu.* + + + nl.ictu.* + + + nl.ictu.pseudoniemenservice.generated.* + + 5000 + false + + + + org.pitest + pitest-junit5-plugin + 1.2.1 + + + + + + mutationCoverage + + pit-report + test + + + org.pitest + ${pitest-maven.version} + + + + + diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index 14d7383..0f4a5e4 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -4,6 +4,6 @@ - + \ No newline at end of file diff --git a/src/main/java/nl/ictu/Identifier.java b/src/main/java/nl/ictu/Identifier.java deleted file mode 100644 index c255372..0000000 --- a/src/main/java/nl/ictu/Identifier.java +++ /dev/null @@ -1,14 +0,0 @@ -package nl.ictu; - -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public final class Identifier { - - @edu.umd.cs.findbugs.annotations.SuppressFBWarnings("SS_SHOULD_BE_STATIC") - private String version = "v1"; - private String bsn; - -} diff --git a/src/main/java/nl/ictu/PseudoniemenServiceApplication.java b/src/main/java/nl/ictu/PseudoniemenServiceApplication.java index 0daa17c..9d4e3bd 100644 --- a/src/main/java/nl/ictu/PseudoniemenServiceApplication.java +++ b/src/main/java/nl/ictu/PseudoniemenServiceApplication.java @@ -1,18 +1,15 @@ package nl.ictu; - import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.security.Security; import lombok.NoArgsConstructor; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import java.security.NoSuchAlgorithmException; -import java.security.Security; - @SuppressWarnings({"HideUtilityClassConstructor"}) @SuppressFBWarnings(value = "EI_EXPOSE_STATIC_REP2", - justification = "nl.ictu.PseudoniemenServiceApplication$$SpringCGLIB$$0") + justification = "nl.ictu.PseudoniemenServiceApplication$$SpringCGLIB$$0") @SpringBootApplication @NoArgsConstructor public class PseudoniemenServiceApplication { @@ -21,7 +18,8 @@ public class PseudoniemenServiceApplication { Security.addProvider(new BouncyCastleProvider()); } - public static void main(final String[] args) throws NoSuchAlgorithmException { + public static void main(final String[] args) { + SpringApplication.run(PseudoniemenServiceApplication.class, args); } } diff --git a/src/main/java/nl/ictu/Token.java b/src/main/java/nl/ictu/Token.java deleted file mode 100644 index 38c4d21..0000000 --- a/src/main/java/nl/ictu/Token.java +++ /dev/null @@ -1,17 +0,0 @@ -package nl.ictu; - -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public final class Token { - - @SuppressFBWarnings("SS_SHOULD_BE_STATIC") - private String version = "v1"; - private String bsn; - private String recipientOIN; - private Long creationDate; - -} diff --git a/src/main/java/nl/ictu/configuration/PseudoniemenServiceProperties.java b/src/main/java/nl/ictu/configuration/PseudoniemenServiceProperties.java index ae00690..142c982 100644 --- a/src/main/java/nl/ictu/configuration/PseudoniemenServiceProperties.java +++ b/src/main/java/nl/ictu/configuration/PseudoniemenServiceProperties.java @@ -1,21 +1,46 @@ package nl.ictu.configuration; -import lombok.Getter; -import lombok.Setter; +import jakarta.annotation.PostConstruct; +import lombok.Data; import lombok.experimental.Accessors; +import nl.ictu.service.exception.IdentifierPrivateKeyException; +import nl.ictu.service.exception.TokenPrivateKeyException; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; @Component @ConfigurationProperties(prefix = "pseudoniemenservice") -@Getter -@Setter +@Data @Accessors(chain = true) -public class PseudoniemenServiceProperties { +public final class PseudoniemenServiceProperties { private String tokenPrivateKey; private String identifierPrivateKey; + /** + * Validates that the required private keys for the token and identifier are set. + * + * This method performs a post-construction validation of the `PseudoniemenServiceProperties` object to ensure that + * the `tokenPrivateKey` and `identifierPrivateKey` are properly configured. If either of these properties is not set + * or is empty, specific exceptions are thrown: + * + * - If `tokenPrivateKey` is null or empty, a {@link TokenPrivateKeyException} is thrown. + * - If `identifierPrivateKey` is null or empty, a {@link IdentifierPrivateKeyException} is thrown. + * + * @throws TokenPrivateKeyException if the `tokenPrivateKey` is missing or empty. + * @throws IdentifierPrivateKeyException if the `identifierPrivateKey` is missing or empty. + */ + @PostConstruct + public void validate() { + + if (!StringUtils.hasText(tokenPrivateKey)) { + throw new TokenPrivateKeyException("Please set a private token key"); + } + if (!StringUtils.hasText(identifierPrivateKey)) { + throw new IdentifierPrivateKeyException("Please set a private identifier key"); + } + } } diff --git a/src/main/java/nl/ictu/controller/GlobalExceptionHandler.java b/src/main/java/nl/ictu/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..6c3be76 --- /dev/null +++ b/src/main/java/nl/ictu/controller/GlobalExceptionHandler.java @@ -0,0 +1,131 @@ +package nl.ictu.controller; + +import lombok.extern.slf4j.Slf4j; +import nl.ictu.service.exception.InvalidOINException; +import nl.ictu.service.exception.IdentifierPrivateKeyException; +import nl.ictu.service.exception.InvalidWsIdentifierRequestTypeException; +import nl.ictu.service.exception.InvalidWsIdentifierTokenException; +import nl.ictu.service.exception.TokenPrivateKeyException; +import nl.ictu.service.exception.WsGetTokenProcessingException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +@Slf4j +@ControllerAdvice +public class GlobalExceptionHandler { + + /** + * Handles generic exceptions and returns an appropriate HTTP response with an error message. + * + * @param ex the Exception to be handled + * @return a ResponseEntity containing a generic error message and an INTERNAL_SERVER_ERROR + * (500) status + */ + @ExceptionHandler(Exception.class) + @ResponseBody + public ResponseEntity handleGenericException(final Exception ex) { + + log.error("Unexpected error occurred", ex); + return new ResponseEntity<>( + "An unexpected error occurred: " + ex.getMessage(), + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + + /** + * Handles exceptions of type IdentifierPrivateKeyException and returns an appropriate HTTP + * response with the exception message. + * + * @param ex the IdentifierPrivateKeyException to be handled + * @return a ResponseEntity containing the exception message and an UNPROCESSABLE_ENTITY (422) + * status + */ + @ExceptionHandler(IdentifierPrivateKeyException.class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + @ResponseBody + public String handleIdentifierPrivateKeyException(final IdentifierPrivateKeyException ex) { + + return ex.getMessage(); + } + + /** + * Handles exceptions of type InvalidWsIdentifierRequestTypeException and returns the exception + * message. This handler sets the HTTP response status to UNPROCESSABLE_ENTITY (422). + * + * @param ex the InvalidWsIdentifierRequestTypeException to be handled + * @return the exception message as a String + */ + @ExceptionHandler(InvalidWsIdentifierRequestTypeException.class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + @ResponseBody + public String handleInvalidWsIdentifierRequestTypeException( + final InvalidWsIdentifierRequestTypeException ex) { + + return ex.getMessage(); + } + + /** + * Handles exceptions of type InvalidWsIdentifierTokenException and returns an appropriate HTTP + * response with the exception message. + * + * @param ex the InvalidWsIdentifierTokenException to be handled + * @return the exception message as a String + */ + @ExceptionHandler(InvalidWsIdentifierTokenException.class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + @ResponseBody + public String handleInvalidWsIdentifierTokenException( + final InvalidWsIdentifierTokenException ex) { + + return ex.getMessage(); + } + + /** + * Handles exceptions of type TokenPrivateKeyException and returns an appropriate HTTP response + * with the exception message. + * + * @param ex the TokenPrivateKeyException to be handled + * @return the exception message as a String + */ + @ExceptionHandler(TokenPrivateKeyException.class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + @ResponseBody + public String handleTokenPrivateKeyException(final TokenPrivateKeyException ex) { + + return ex.getMessage(); + } + + /** + * Handles exceptions of type WsGetTokenProcessingException and returns an appropriate HTTP + * response with the exception message. + * + * @param ex the WsGetTokenProcessingException to be handled + * @return the exception message as a String + */ + @ExceptionHandler(WsGetTokenProcessingException.class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + @ResponseBody + public String handleWsGetTokenProcessingException(final WsGetTokenProcessingException ex) { + + return ex.getMessage(); + } + + /** + * Handles exceptions of type InvalidOINException and returns the exception message. This + * handler sets the HTTP response status to UNPROCESSABLE_ENTITY (422). + * + * @param ex the InvalidOINException to be handled + * @return the exception message as a String + */ + @ExceptionHandler(InvalidOINException.class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + @ResponseBody + public String handleInvalidOINException(final InvalidOINException ex) { + + return ex.getMessage(); + } +} diff --git a/src/main/java/nl/ictu/controller/v1/ExchangeIdentifier.java b/src/main/java/nl/ictu/controller/v1/ExchangeIdentifier.java deleted file mode 100644 index 4271028..0000000 --- a/src/main/java/nl/ictu/controller/v1/ExchangeIdentifier.java +++ /dev/null @@ -1,90 +0,0 @@ -package nl.ictu.controller.v1; - -import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; -import nl.ictu.Identifier; -import nl.ictu.pseudoniemenservice.generated.server.api.ExchangeIdentifierApi; -import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierRequest; -import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierResponse; -import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifier; -import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes; -import nl.ictu.service.AesGcmSivCryptographer; -import org.bouncycastle.crypto.InvalidCipherTextException; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RestController; - -import java.io.IOException; - -import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.BSN; -import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.ORGANISATION_PSEUDO; -import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; - -@RequiredArgsConstructor -@RestController -public final class ExchangeIdentifier implements ExchangeIdentifierApi, VersionOneController { - - private final AesGcmSivCryptographer aesGcmSivCryptographer; - - @Override - @SneakyThrows - public ResponseEntity exchangeIdentifier(final String callerOIN, final WsExchangeIdentifierRequest wsExchangeIdentifierForIdentifierRequest) { - - final WsIdentifier wsIdentifierRequest = wsExchangeIdentifierForIdentifierRequest.getIdentifier(); - - final String recipientOIN = wsExchangeIdentifierForIdentifierRequest.getRecipientOIN(); - - final WsIdentifierTypes recipientIdentifierType = wsExchangeIdentifierForIdentifierRequest.getRecipientIdentifierType(); - - if (BSN.equals(wsIdentifierRequest.getType()) && ORGANISATION_PSEUDO.equals(recipientIdentifierType)) { - // from BSN to Org Pseudo - return ResponseEntity.ok(convertBsnToPseudo(wsIdentifierRequest.getValue(), recipientOIN)); - - } else if (ORGANISATION_PSEUDO.equals(wsIdentifierRequest.getType()) && BSN.equals(recipientIdentifierType)) { - // from BSN to Org Pseudo - return ResponseEntity.ok(convertPseudoToBEsn(wsIdentifierRequest.getValue(), recipientOIN)); - - } else { - return ResponseEntity.status(UNPROCESSABLE_ENTITY).build(); - } - - - } - - private WsExchangeIdentifierResponse convertBsnToPseudo(final String bsn, final String oin) throws IOException, InvalidCipherTextException { - - final Identifier identifier = new Identifier(); - - identifier.setBsn(bsn); - - final String oinNencyptedIdentifier = aesGcmSivCryptographer.encrypt(identifier, oin); - - final WsExchangeIdentifierResponse wsExchangeTokenForIdentifier200Response = new WsExchangeIdentifierResponse(); - - final WsIdentifier wsIdentifierResponse = new WsIdentifier(); - - wsIdentifierResponse.setType(ORGANISATION_PSEUDO); - wsIdentifierResponse.setValue(oinNencyptedIdentifier); - - wsExchangeTokenForIdentifier200Response.setIdentifier(wsIdentifierResponse); - - return wsExchangeTokenForIdentifier200Response; - - } - - private WsExchangeIdentifierResponse convertPseudoToBEsn(final String pseudo, final String oin) throws IOException, InvalidCipherTextException { - - final Identifier identifier = aesGcmSivCryptographer.decrypt(pseudo, oin); - - final WsExchangeIdentifierResponse wsExchangeTokenForIdentifier200Response = new WsExchangeIdentifierResponse(); - - final WsIdentifier wsIdentifierResponse = new WsIdentifier(); - - wsIdentifierResponse.setType(BSN); - wsIdentifierResponse.setValue(identifier.getBsn()); - - wsExchangeTokenForIdentifier200Response.setIdentifier(wsIdentifierResponse); - - return wsExchangeTokenForIdentifier200Response; - - } -} diff --git a/src/main/java/nl/ictu/controller/v1/ExchangeIdentifierController.java b/src/main/java/nl/ictu/controller/v1/ExchangeIdentifierController.java new file mode 100644 index 0000000..911fff2 --- /dev/null +++ b/src/main/java/nl/ictu/controller/v1/ExchangeIdentifierController.java @@ -0,0 +1,36 @@ +package nl.ictu.controller.v1; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nl.ictu.pseudoniemenservice.generated.server.api.ExchangeIdentifierApi; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierRequest; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierResponse; +import nl.ictu.service.ExchangeIdentifierService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +public final class ExchangeIdentifierController implements ExchangeIdentifierApi, + VersionOneController { + + private final ExchangeIdentifierService service; + + /** + * Exchanges an identifier based on the provided caller OIN and request data. + * + * @param callerOIN The OIN of the caller initiating the request. + * @param wsExchangeRequest The request object containing the identifier and additional data for + * the exchange process. + * @return A ResponseEntity containing a WsExchangeIdentifierResponse if the exchange is + * successful, or a ResponseEntity with HTTP status UNPROCESSABLE_ENTITY if the exchange fails. + */ + @Override + public ResponseEntity exchangeIdentifier(final String callerOIN, + final WsExchangeIdentifierRequest wsExchangeRequest) { + + final var identifier = service.exchangeIdentifier(wsExchangeRequest); + return ResponseEntity.ok(identifier); + } +} diff --git a/src/main/java/nl/ictu/controller/v1/ExchangeToken.java b/src/main/java/nl/ictu/controller/v1/ExchangeToken.java deleted file mode 100644 index 116fec4..0000000 --- a/src/main/java/nl/ictu/controller/v1/ExchangeToken.java +++ /dev/null @@ -1,78 +0,0 @@ -package nl.ictu.controller.v1; - -import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; -import nl.ictu.Identifier; -import nl.ictu.Token; -import nl.ictu.pseudoniemenservice.generated.server.api.ExchangeTokenApi; -import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenRequest; -import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenResponse; -import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifier; -import nl.ictu.service.AesGcmCryptographer; -import nl.ictu.service.AesGcmSivCryptographer; -import nl.ictu.service.TokenConverter; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RestController; - -import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.BSN; -import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.ORGANISATION_PSEUDO; -import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; - -@Slf4j -@RequiredArgsConstructor -@RestController -public final class ExchangeToken implements ExchangeTokenApi, VersionOneController { - - private final AesGcmCryptographer aesGcmCryptographer; - - private final AesGcmSivCryptographer aesGcmSivCryptographer; - - private final TokenConverter tokenConverter; - - @Override - @SneakyThrows - public ResponseEntity exchangeToken(final String callerOIN, final WsExchangeTokenRequest wsExchangeTokenForIdentifierRequest) { - - final String encodedToken = aesGcmCryptographer.decrypt(wsExchangeTokenForIdentifierRequest.getToken(), callerOIN); - - final Token token = tokenConverter.decode(encodedToken); - - if (!callerOIN.equals(token.getRecipientOIN())) { - throw new RuntimeException("Sink OIN not the same"); - } - - final WsExchangeTokenResponse wsExchangeTokenForIdentifier200Response = new WsExchangeTokenResponse(); - - final WsIdentifier wsIdentifier = new WsIdentifier(); - - switch (wsExchangeTokenForIdentifierRequest.getIdentifierType()) { - case BSN -> { - wsIdentifier.setType(BSN); - wsIdentifier.setValue(token.getBsn()); - } - case ORGANISATION_PSEUDO -> { - - final Identifier identifier = new Identifier(); - - identifier.setBsn(token.getBsn()); - - final String encrypt = aesGcmSivCryptographer.encrypt(identifier, callerOIN); - - wsIdentifier.setType(ORGANISATION_PSEUDO); - wsIdentifier.setValue(encrypt); - - } - default -> { - return ResponseEntity.status(UNPROCESSABLE_ENTITY).build(); - } - - } - - - wsExchangeTokenForIdentifier200Response.setIdentifier(wsIdentifier); - - return ResponseEntity.ok(wsExchangeTokenForIdentifier200Response); - - } -} diff --git a/src/main/java/nl/ictu/controller/v1/ExchangeTokenController.java b/src/main/java/nl/ictu/controller/v1/ExchangeTokenController.java new file mode 100644 index 0000000..3bd8c9a --- /dev/null +++ b/src/main/java/nl/ictu/controller/v1/ExchangeTokenController.java @@ -0,0 +1,38 @@ +package nl.ictu.controller.v1; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nl.ictu.pseudoniemenservice.generated.server.api.ExchangeTokenApi; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenRequest; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenResponse; +import nl.ictu.service.ExchangeTokenService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RequiredArgsConstructor +@RestController +public final class ExchangeTokenController implements ExchangeTokenApi, VersionOneController { + + private final ExchangeTokenService exchangeTokenService; + + /** + * Handles the exchange of a token and returns the corresponding identifier in a response. This + * method validates the caller's OIN, processes the incoming token using the specified + * identifier type, and constructs a response accordingly. + * + * @param callerOIN The identifier of the requesting organization + * (OIN). + * @param wsExchangeTokenForIdentifierRequest The request containing the token and identifier + * type details. + * @return A response entity containing the converted identifier or a status indicating failure. + */ + @Override + public ResponseEntity exchangeToken(final String callerOIN, + final WsExchangeTokenRequest wsExchangeTokenForIdentifierRequest) { + + final var wsExchangeTokenResponse = exchangeTokenService.exchangeToken(callerOIN, + wsExchangeTokenForIdentifierRequest); + return ResponseEntity.ok(wsExchangeTokenResponse); + } +} diff --git a/src/main/java/nl/ictu/controller/v1/GetToken.java b/src/main/java/nl/ictu/controller/v1/GetToken.java deleted file mode 100644 index 0a2395c..0000000 --- a/src/main/java/nl/ictu/controller/v1/GetToken.java +++ /dev/null @@ -1,66 +0,0 @@ -package nl.ictu.controller.v1; - -import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; -import nl.ictu.Identifier; -import nl.ictu.Token; -import nl.ictu.pseudoniemenservice.generated.server.api.GetTokenApi; -import nl.ictu.pseudoniemenservice.generated.server.model.WsGetTokenRequest; -import nl.ictu.pseudoniemenservice.generated.server.model.WsGetTokenResponse; -import nl.ictu.service.AesGcmCryptographer; -import nl.ictu.service.AesGcmSivCryptographer; -import nl.ictu.service.TokenConverter; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RestController; - -import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; - -@RestController -@RequiredArgsConstructor -public final class GetToken implements GetTokenApi, VersionOneController { - - private final AesGcmCryptographer aesGcmCryptographer; - - private final AesGcmSivCryptographer aesGcmSivCryptographer; - - private final TokenConverter tokenConverter; - - @Override - @SneakyThrows - public ResponseEntity getToken(final String callerOIN, final WsGetTokenRequest wsGetTokenRequest) { - - // check is callerOIN allowed to communicatie with sinkOIN - - final WsGetTokenResponse wsGetToken200Response = new WsGetTokenResponse(); - - final Token token = new Token(); - - token.setCreationDate(System.currentTimeMillis()); - token.setRecipientOIN(wsGetTokenRequest.getRecipientOIN()); - - if (wsGetTokenRequest.getIdentifier() != null) { - switch (wsGetTokenRequest.getIdentifier().getType()) { - case BSN -> token.setBsn(wsGetTokenRequest.getIdentifier().getValue()); - case ORGANISATION_PSEUDO -> { - - final String orgPseudoEncryptedString = wsGetTokenRequest.getIdentifier().getValue(); - - final Identifier decodedIdentifier = aesGcmSivCryptographer.decrypt(orgPseudoEncryptedString, wsGetTokenRequest.getRecipientOIN()); - - token.setBsn(decodedIdentifier.getBsn()); - - } - default -> { - return ResponseEntity.status(UNPROCESSABLE_ENTITY).build(); - } - } - } - - final String plainTextToken = tokenConverter.encode(token); - - wsGetToken200Response.token(aesGcmCryptographer.encrypt(plainTextToken, wsGetTokenRequest.getRecipientOIN())); - - return ResponseEntity.ok(wsGetToken200Response); - } - -} diff --git a/src/main/java/nl/ictu/controller/v1/GetTokenController.java b/src/main/java/nl/ictu/controller/v1/GetTokenController.java new file mode 100644 index 0000000..a7a94cf --- /dev/null +++ b/src/main/java/nl/ictu/controller/v1/GetTokenController.java @@ -0,0 +1,38 @@ +package nl.ictu.controller.v1; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import nl.ictu.pseudoniemenservice.generated.server.api.GetTokenApi; +import nl.ictu.pseudoniemenservice.generated.server.model.WsGetTokenRequest; +import nl.ictu.pseudoniemenservice.generated.server.model.WsGetTokenResponse; +import nl.ictu.service.GetTokenService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +public final class GetTokenController implements GetTokenApi, VersionOneController { + + private final GetTokenService getTokenService; + + /** + * Retrieves a token based on the provided caller identifier and request details. + * + * @param callerOIN The identifier of the caller organization initiating the request. + * @param wsGetTokenRequest The request object containing the recipient organization identifier + * and additional details. + * @return A ResponseEntity containing the token if the request is successful, or a + * ResponseEntity with a status of UNPROCESSABLE_ENTITY if the token cannot be retrieved. + */ + @Override + public ResponseEntity getToken(final String callerOIN, + final WsGetTokenRequest wsGetTokenRequest) { + + final var recipientOIN = wsGetTokenRequest.getRecipientOIN(); + final var identifier = wsGetTokenRequest.getIdentifier(); + final var wsGetTokenResponse = getTokenService.getWsGetTokenResponse( + recipientOIN, identifier); + return ResponseEntity.ok(wsGetTokenResponse); + } +} diff --git a/src/main/java/nl/ictu/crypto/AesGcmCryptographer.java b/src/main/java/nl/ictu/crypto/AesGcmCryptographer.java new file mode 100644 index 0000000..db8fb9c --- /dev/null +++ b/src/main/java/nl/ictu/crypto/AesGcmCryptographer.java @@ -0,0 +1,132 @@ +package nl.ictu.crypto; + +import static nl.ictu.utils.AesUtility.IV_LENGTH; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import lombok.RequiredArgsConstructor; +import nl.ictu.configuration.PseudoniemenServiceProperties; +import nl.ictu.utils.AesUtility; +import nl.ictu.utils.Base64Wrapper; +import nl.ictu.utils.ByteArrayUtil; +import nl.ictu.utils.MessageDigestWrapper; +import org.springframework.stereotype.Component; + +/** + * Advanced Encryption Standard Galois/Counter Mode (AES-GCM). + */ +@Component +@RequiredArgsConstructor +public class AesGcmCryptographer { + + private final Base64Wrapper base64Wrapper; + private final MessageDigestWrapper messageDigestWrapper; + private final PseudoniemenServiceProperties pseudoniemenServiceProperties; + + /** + * Encrypts the given plaintext using the Advanced Encryption Standard in Galois/Counter Mode + * (AES-GCM) and a provided salt. The resulting ciphertext is Base64 encoded and includes the IV + * used during encryption, concatenated with the encrypted data. + * + * @param plaintext the plaintext message to be encrypted + * @param salt the salt value used to derive the encryption key + * @return the Base64 encoded ciphertext, including the IV + * @throws IllegalBlockSizeException if the block size is invalid during the encryption + * process + * @throws BadPaddingException if there are issues with padding during + * encryption + * @throws InvalidAlgorithmParameterException if the provided algorithm parameters are invalid + * @throws InvalidKeyException if the encryption key is invalid + * @throws NoSuchAlgorithmException if the requested encryption algorithm is not + * available + * @throws NoSuchPaddingException if the requested padding scheme is not available + */ + public String encrypt(final String plaintext, final String salt) + throws IllegalBlockSizeException, + BadPaddingException, + InvalidAlgorithmParameterException, + InvalidKeyException, + NoSuchAlgorithmException, + NoSuchPaddingException { + + if (plaintext == null || plaintext.isEmpty()) { + throw new IllegalArgumentException("Plaintext cannot be null or empty"); + } + if (salt == null || salt.isEmpty()) { + throw new IllegalArgumentException("Salt cannot be null or empty"); + } + final var cipher = AesUtility.createCipher(); + final var gcmParameterSpec = AesUtility.generateIV(); + final var secretKey = createSecretKey(salt); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmParameterSpec); + final var ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + final var gcmIV = gcmParameterSpec.getIV(); + final var encryptedWithIV = ByteArrayUtil.concat(gcmIV, ciphertext); + return base64Wrapper.encodeToString(encryptedWithIV); + } + + /** + * Creates a secret encryption key by combining a base64-decoded private key with a given salt, + * and hashing the result using SHA-256. + * + * @param salt the salt value used to modify the private key and derive the final encryption + * key + * @return a SecretKey instance derived from the combined and hashed input + */ + private SecretKey createSecretKey(final String salt) { + + final var keyBytes = base64Wrapper.decode( + pseudoniemenServiceProperties.getTokenPrivateKey()); + final var saltBytes = salt.getBytes(StandardCharsets.UTF_8); + final var salterSecretBytes = ByteArrayUtil.concat(keyBytes, saltBytes); + final var key = messageDigestWrapper.getMessageDigestInstance().digest(salterSecretBytes); + return new SecretKeySpec(key, "AES"); + } + + /** + * Decrypts the given Base64 encoded ciphertext, which includes the initialization vector (IV), + * using the Advanced Encryption Standard in Galois/Counter Mode (AES-GCM) with a provided + * salt. + * + * @param ciphertextWithIv the Base64 encoded encrypted data, including the IV + * @param salt the salt value used to derive the decryption key + * @return the decrypted plaintext as a UTF-8 string + * @throws NoSuchPaddingException if the requested padding scheme is not available + * @throws NoSuchAlgorithmException if the requested encryption algorithm is not + * available + * @throws InvalidAlgorithmParameterException if the provided algorithm parameters are invalid + * @throws InvalidKeyException if the decryption key is invalid + * @throws IllegalBlockSizeException if the block size is invalid during the decryption + * process + * @throws BadPaddingException if there are issues with padding during + * decryption + */ + public String decrypt(final String ciphertextWithIv, final String salt) + throws NoSuchPaddingException, + NoSuchAlgorithmException, + InvalidAlgorithmParameterException, + InvalidKeyException, + IllegalBlockSizeException, + BadPaddingException { + + final var cipher = AesUtility.createCipher(); + final var encryptedWithIV = base64Wrapper.decode(ciphertextWithIv); + final var iv = Arrays.copyOfRange(encryptedWithIV, 0, IV_LENGTH); + final var ciphertext = Arrays.copyOfRange(encryptedWithIV, IV_LENGTH, + encryptedWithIV.length); + final var gcmParameterSpec = AesUtility.createIVfromValues(iv); + final var secretKey = createSecretKey(salt); + cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmParameterSpec); + final var decryptedText = cipher.doFinal(ciphertext); + return new String(decryptedText, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/nl/ictu/crypto/AesGcmSivCryptographer.java b/src/main/java/nl/ictu/crypto/AesGcmSivCryptographer.java new file mode 100644 index 0000000..bd4b4ae --- /dev/null +++ b/src/main/java/nl/ictu/crypto/AesGcmSivCryptographer.java @@ -0,0 +1,103 @@ +package nl.ictu.crypto; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import nl.ictu.configuration.PseudoniemenServiceProperties; +import nl.ictu.model.Identifier; +import nl.ictu.utils.AesUtility; +import nl.ictu.utils.Base64Wrapper; +import nl.ictu.utils.MessageDigestWrapper; +import org.bouncycastle.crypto.InvalidCipherTextException; +import org.bouncycastle.crypto.modes.GCMSIVBlockCipher; +import org.bouncycastle.crypto.params.AEADParameters; +import org.bouncycastle.crypto.params.KeyParameter; +import org.springframework.stereotype.Component; + +/** + * Advanced Encryption Standard Galois/Counter Mode synthetic initialization vector. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AesGcmSivCryptographer { + + public static final int MAC_SIZE = 128; + private static final int NONCE_LENTH = 12; + private final PseudoniemenServiceProperties pseudoniemenServiceProperties; + private final MessageDigestWrapper messageDigestWrapper; + private final IdentifierConverter identifierConverter; + private final Base64Wrapper base64Wrapper; + + /** + * Creates AEADParameters using the given salt to generate a nonce and a private key for the + * encryption process. + * + * @param salt the salt used to derive the nonce for the encryption process + * @return AEADParameters containing the key, MAC size, and nonce for encryption + */ + private AEADParameters createSecretKey(final String salt) { + + final var nonce16 = messageDigestWrapper.getMessageDigestInstance() + .digest(salt.getBytes(StandardCharsets.UTF_8)); + final var nonce12 = Arrays.copyOf(nonce16, NONCE_LENTH); + final var identifierPrivateKey = pseudoniemenServiceProperties.getIdentifierPrivateKey(); + final var keyParameter = new KeyParameter( + base64Wrapper.decode(identifierPrivateKey)); + return new AEADParameters(keyParameter, MAC_SIZE, nonce12); + } + + /** + * Encrypts the given {@code Identifier} using a salt and returns the resulting Base64-encoded + * ciphertext. This method leverages AES-GCM-SIV encryption for secure and authenticated + * encryption. + * + * @param identifier the identifier object to be encrypted + * @param salt a string used to derive a nonce and key for encryption + * @return the Base64-encoded string representation of the ciphertext + * @throws InvalidCipherTextException if encryption process fails + * @throws IOException if an I/O error occurs during encryption + */ + public String encrypt(final Identifier identifier, final String salt) + throws InvalidCipherTextException, IOException { + + final var plaintext = identifierConverter.encode(identifier); + final var cipher = new GCMSIVBlockCipher(AesUtility.getAESEngine()); + cipher.init(true, createSecretKey(salt)); + final var plainTextBytes = plaintext.getBytes(StandardCharsets.UTF_8); + final var ciphertext = new byte[cipher.getOutputSize(plainTextBytes.length)]; + final var outputLength = cipher.processBytes(plainTextBytes, 0, plainTextBytes.length, + ciphertext, 0); + cipher.doFinal(ciphertext, outputLength); + cipher.reset(); + return base64Wrapper.encodeToString(ciphertext); + } + + /** + * Decrypts the given Base64-encoded ciphertext string using the provided salt. This method uses + * AES-GCM-SIV decryption to securely retrieve the original plaintext. + * + * @param ciphertextString the Base64-encoded string containing the ciphertext to be decrypted + * @param salt a string used to derive the nonce and key for decryption + * @return the decrypted {@code Identifier} object + */ + @SneakyThrows + public Identifier decrypt(final String ciphertextString, final String salt) { + + final var cipher = new GCMSIVBlockCipher(AesUtility.getAESEngine()); + cipher.init(false, createSecretKey(salt)); + final var ciphertext = base64Wrapper.decode(ciphertextString); + final var plaintext = new byte[cipher.getOutputSize(ciphertext.length)]; + final var outputLength = cipher.processBytes(ciphertext, 0, ciphertext.length, plaintext, + 0); + cipher.doFinal(plaintext, outputLength); + cipher.reset(); + final var encodedIdentifier = new String(plaintext, StandardCharsets.UTF_8); + return identifierConverter.decode(encodedIdentifier); + } +} + + diff --git a/src/main/java/nl/ictu/service/IdentifierConverterImpl.java b/src/main/java/nl/ictu/crypto/IdentifierConverter.java similarity index 51% rename from src/main/java/nl/ictu/service/IdentifierConverterImpl.java rename to src/main/java/nl/ictu/crypto/IdentifierConverter.java index 542969d..2332b61 100644 --- a/src/main/java/nl/ictu/service/IdentifierConverterImpl.java +++ b/src/main/java/nl/ictu/crypto/IdentifierConverter.java @@ -1,34 +1,44 @@ -package nl.ictu.service; +package nl.ictu.crypto; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import nl.ictu.Identifier; -import org.springframework.aot.hint.annotation.RegisterReflectionForBinding; -import org.springframework.stereotype.Service; - import java.io.IOException; import java.io.StringWriter; +import lombok.RequiredArgsConstructor; +import nl.ictu.model.Identifier; +import org.springframework.aot.hint.annotation.RegisterReflectionForBinding; +import org.springframework.stereotype.Component; -@SuppressWarnings("DesignForExtension") -@Service +@Component @RequiredArgsConstructor @RegisterReflectionForBinding({Identifier.class}) -public class IdentifierConverterImpl implements IdentifierConverter { +public class IdentifierConverter { private final ObjectMapper objectMapper; - @Override + /** + * Encodes the given Identifier object into its JSON representation as a string. + * + * @param identifier the Identifier object to be encoded + * @return the JSON string representation of the given Identifier object + * @throws IOException if an I/O error occurs during encoding + */ public String encode(final Identifier identifier) throws IOException { + final StringWriter stringWriter = new StringWriter(); objectMapper.writeValue(stringWriter, identifier); return stringWriter.toString(); } - @Override + /** + * Decodes a JSON string into an Identifier object. + * + * @param encodedIdentifier the JSON string representation of an Identifier object + * @return the deserialized Identifier object + * @throws JsonProcessingException if an error occurs while processing the JSON string + */ public Identifier decode(final String encodedIdentifier) throws JsonProcessingException { + return objectMapper.readValue(encodedIdentifier, Identifier.class); } - - } diff --git a/src/main/java/nl/ictu/crypto/TokenCoder.java b/src/main/java/nl/ictu/crypto/TokenCoder.java new file mode 100644 index 0000000..1be9651 --- /dev/null +++ b/src/main/java/nl/ictu/crypto/TokenCoder.java @@ -0,0 +1,44 @@ +package nl.ictu.crypto; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.StringWriter; +import lombok.RequiredArgsConstructor; +import nl.ictu.model.Token; +import org.springframework.aot.hint.annotation.RegisterReflectionForBinding; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@RegisterReflectionForBinding({Token.class}) +public class TokenCoder { + + private final ObjectMapper objectMapper; + + /** + * Encodes the given Token object to its JSON string representation. + * + * @param token the Token object to be encoded + * @return the JSON string representation of the given Token object + * @throws IOException if an I/O error occurs during encoding + */ + public String encode(final Token token) throws IOException { + + final var stringWriter = new StringWriter(); + objectMapper.writeValue(stringWriter, token); + return stringWriter.toString(); + } + + /** + * Decodes a JSON-encoded token string into a Token object. + * + * @param encodedToken the JSON string representation of a Token + * @return the decoded Token object + * @throws JsonProcessingException if the JSON string cannot be parsed into a Token object + */ + public Token decode(final String encodedToken) throws JsonProcessingException { + + return objectMapper.readValue(encodedToken, Token.class); + } +} diff --git a/src/main/java/nl/ictu/model/Identifier.java b/src/main/java/nl/ictu/model/Identifier.java new file mode 100644 index 0000000..1fc0f6b --- /dev/null +++ b/src/main/java/nl/ictu/model/Identifier.java @@ -0,0 +1,17 @@ +package nl.ictu.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Identifier { + + private String version; + private String bsn; +} diff --git a/src/main/java/nl/ictu/model/Token.java b/src/main/java/nl/ictu/model/Token.java new file mode 100644 index 0000000..c70398e --- /dev/null +++ b/src/main/java/nl/ictu/model/Token.java @@ -0,0 +1,17 @@ +package nl.ictu.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +public class Token { + + private String version; + private String bsn; + private String recipientOIN; + private Long creationDate; + +} diff --git a/src/main/java/nl/ictu/service/AESHelper.java b/src/main/java/nl/ictu/service/AESHelper.java deleted file mode 100644 index 3c6cdcb..0000000 --- a/src/main/java/nl/ictu/service/AESHelper.java +++ /dev/null @@ -1,39 +0,0 @@ -package nl.ictu.service; - -import javax.crypto.Cipher; -import javax.crypto.NoSuchPaddingException; -import javax.crypto.spec.GCMParameterSpec; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; - -public final class AESHelper { - - private AESHelper() { - } - - public static final int IV_LENGTH = 12; - - private static final int TAG_LENGTH = 128; - - private static final String CIPHER = "AES/GCM/NoPadding"; - - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); - - // Method to generate a random Initialization Vector (IV) - public static GCMParameterSpec generateIV() { - byte[] iv = new byte[IV_LENGTH]; // AES block size is 16 bytes - SECURE_RANDOM.nextBytes(iv); - - return new GCMParameterSpec(TAG_LENGTH, iv); - - } - - public static GCMParameterSpec createIVfromValues(final byte[] iv) { - return new GCMParameterSpec(TAG_LENGTH, iv); - } - - public static Cipher createCipher() throws NoSuchPaddingException, NoSuchAlgorithmException { - return Cipher.getInstance(CIPHER); - } - -} diff --git a/src/main/java/nl/ictu/service/AesGcmCryptographer.java b/src/main/java/nl/ictu/service/AesGcmCryptographer.java deleted file mode 100644 index 5e83c89..0000000 --- a/src/main/java/nl/ictu/service/AesGcmCryptographer.java +++ /dev/null @@ -1,15 +0,0 @@ -package nl.ictu.service; - -import javax.crypto.BadPaddingException; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; -import java.security.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; - -public interface AesGcmCryptographer { - - String encrypt(String plaintext, String salt) throws IllegalBlockSizeException, BadPaddingException, InvalidAlgorithmParameterException, InvalidKeyException, NoSuchAlgorithmException, NoSuchPaddingException; - - String decrypt(String ciphertext, String salt) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException; -} diff --git a/src/main/java/nl/ictu/service/AesGcmCryptographerImpl.java b/src/main/java/nl/ictu/service/AesGcmCryptographerImpl.java deleted file mode 100644 index 21bf2da..0000000 --- a/src/main/java/nl/ictu/service/AesGcmCryptographerImpl.java +++ /dev/null @@ -1,116 +0,0 @@ -package nl.ictu.service; - -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import lombok.SneakyThrows; -import nl.ictu.configuration.PseudoniemenServiceProperties; -import nl.ictu.utils.ByteArrayUtils; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -import javax.crypto.BadPaddingException; -import javax.crypto.Cipher; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; -import javax.crypto.SecretKey; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.security.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Arrays; -import java.util.Base64; - -import static nl.ictu.service.AESHelper.IV_LENGTH; - -/** - * Advanced Encryption Standard Galois/Counter Mode (AES-GCM). - */ - -@SuppressWarnings("DesignForExtension") -@Service -public class AesGcmCryptographerImpl implements AesGcmCryptographer { - - //private SecretKey secretKey; - - private final Base64.Encoder base64Encoder = Base64.getEncoder(); - - private final Base64.Decoder base64Decoder = Base64.getDecoder(); - - private final MessageDigest sha256Digest; - - private final PseudoniemenServiceProperties pseudoniemenServiceProperties; - - @SuppressFBWarnings("CT_CONSTRUCTOR_THROW") - @SneakyThrows - public AesGcmCryptographerImpl(final PseudoniemenServiceProperties pseudoniemenServicePropertiesArg) { - - pseudoniemenServiceProperties = pseudoniemenServicePropertiesArg; - - sha256Digest = MessageDigest.getInstance("SHA-256"); - - if (!StringUtils.hasText(pseudoniemenServiceProperties.getTokenPrivateKey())) { - throw new RuntimeException("Please set a private token key"); - } - - } - - @Override - public String encrypt(final String plaintext, final String salt) throws IllegalBlockSizeException, BadPaddingException, InvalidAlgorithmParameterException, InvalidKeyException, NoSuchAlgorithmException, NoSuchPaddingException { - - final Cipher cipher = AESHelper.createCipher(); - - final GCMParameterSpec gcmParameterSpec = AESHelper.generateIV(); - - final SecretKey secretKey = createSecretKey(salt); - - cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmParameterSpec); - - byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); - - byte[] encryptedWithIV = new byte[IV_LENGTH + ciphertext.length]; - - System.arraycopy(gcmParameterSpec.getIV(), 0, encryptedWithIV, 0, IV_LENGTH); - System.arraycopy(ciphertext, 0, encryptedWithIV, IV_LENGTH, ciphertext.length); - - return base64Encoder.encodeToString(encryptedWithIV); - } - - private SecretKey createSecretKey(final String salt) { - - byte[] keyBytes = base64Decoder.decode(pseudoniemenServiceProperties.getTokenPrivateKey()); - - byte[] saltBytes = salt.getBytes(StandardCharsets.UTF_8); - - byte[] salterSecretBytes = ByteArrayUtils.concat(keyBytes, saltBytes); - - byte[] key = sha256Digest.digest(salterSecretBytes); - - return new SecretKeySpec(key, "AES"); - - } - - @Override - public String decrypt(final String ciphertextWithIv, final String salt) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException { - - final Cipher cipher = AESHelper.createCipher(); - - final byte[] encryptedWithIV = base64Decoder.decode(ciphertextWithIv); - - byte[] iv = Arrays.copyOfRange(encryptedWithIV, 0, IV_LENGTH); - byte[] ciphertext = Arrays.copyOfRange(encryptedWithIV, IV_LENGTH, encryptedWithIV.length); - - final GCMParameterSpec gcmParameterSpec = AESHelper.createIVfromValues(iv); - - final SecretKey secretKey = createSecretKey(salt); - - cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmParameterSpec); - - byte[] decryptedText = cipher.doFinal(ciphertext); - - return new String(decryptedText, StandardCharsets.UTF_8); - - } - -} diff --git a/src/main/java/nl/ictu/service/AesGcmSivCryptographer.java b/src/main/java/nl/ictu/service/AesGcmSivCryptographer.java deleted file mode 100644 index 56872fe..0000000 --- a/src/main/java/nl/ictu/service/AesGcmSivCryptographer.java +++ /dev/null @@ -1,14 +0,0 @@ -package nl.ictu.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import nl.ictu.Identifier; -import org.bouncycastle.crypto.InvalidCipherTextException; - -import java.io.IOException; - -public interface AesGcmSivCryptographer { - - String encrypt(Identifier identifier, String salt) throws InvalidCipherTextException, IOException; - - Identifier decrypt(String ciphertext, String salt) throws InvalidCipherTextException, JsonProcessingException; -} diff --git a/src/main/java/nl/ictu/service/AesGcmSivCryptographerImpl.java b/src/main/java/nl/ictu/service/AesGcmSivCryptographerImpl.java deleted file mode 100644 index 2ecfb19..0000000 --- a/src/main/java/nl/ictu/service/AesGcmSivCryptographerImpl.java +++ /dev/null @@ -1,127 +0,0 @@ -package nl.ictu.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; -import nl.ictu.Identifier; -import nl.ictu.configuration.PseudoniemenServiceProperties; -import org.bouncycastle.crypto.InvalidCipherTextException; -import org.bouncycastle.crypto.MultiBlockCipher; -import org.bouncycastle.crypto.engines.AESEngine; -import org.bouncycastle.crypto.modes.GCMSIVBlockCipher; -import org.bouncycastle.crypto.params.AEADParameters; -import org.bouncycastle.crypto.params.KeyParameter; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Arrays; -import java.util.Base64; - -/** - * Advanced Encryption Standard Galois/Counter Mode synthetic initialization vector. - */ - -@Slf4j -@SuppressWarnings("DesignForExtension") -@Service -public class AesGcmSivCryptographerImpl implements AesGcmSivCryptographer { - - public static final int MAC_SIZE = 128; - - private final PseudoniemenServiceProperties pseudoniemenServiceProperties; - - private static final int NONCE_LENTH = 12; - - private final Base64.Encoder base64Encoder = Base64.getEncoder(); - - private final Base64.Decoder base64Decoder = Base64.getDecoder(); - - private final MultiBlockCipher aesEngine; - - private final MessageDigest sha256Digest; - - private final IdentifierConverter identifierConverter; - - @SuppressFBWarnings("CT_CONSTRUCTOR_THROW") - @SneakyThrows - public AesGcmSivCryptographerImpl(final PseudoniemenServiceProperties pseudoniemenServicePropertiesArg, final IdentifierConverter identifierConverterArg) { - - pseudoniemenServiceProperties = pseudoniemenServicePropertiesArg; - identifierConverter = identifierConverterArg; - - aesEngine = AESEngine.newInstance(); - sha256Digest = MessageDigest.getInstance("SHA-256"); - - if (!StringUtils.hasText(pseudoniemenServiceProperties.getIdentifierPrivateKey())) { - throw new RuntimeException("Please set a private identifier key"); - } - - } - - private AEADParameters createSecretKey(final String salt) { - - final byte[] nonce16 = sha256Digest.digest(salt.getBytes(StandardCharsets.UTF_8)); - - byte[] nonce12 = Arrays.copyOf(nonce16, NONCE_LENTH); - - final String identifierPrivateKey = pseudoniemenServiceProperties.getIdentifierPrivateKey(); - - final KeyParameter keyParameter = new KeyParameter(base64Decoder.decode(identifierPrivateKey)); - - return new AEADParameters(keyParameter, MAC_SIZE, nonce12); - - } - - @Override - public String encrypt(final Identifier identifier, final String salt) throws InvalidCipherTextException, IOException { - - final String plaintext = identifierConverter.encode(identifier); - - final GCMSIVBlockCipher cipher = new GCMSIVBlockCipher(aesEngine); - - cipher.init(true, createSecretKey(salt)); - - final byte[] plainTextBytes = plaintext.getBytes(StandardCharsets.UTF_8); - - final byte[] ciphertext = new byte[cipher.getOutputSize(plainTextBytes.length)]; - - final int outputLength = cipher.processBytes(plainTextBytes, 0, plainTextBytes.length, ciphertext, 0); - - cipher.doFinal(ciphertext, outputLength); - - cipher.reset(); - - return base64Encoder.encodeToString(ciphertext); - - } - - @Override - public Identifier decrypt(final String ciphertextString, final String salt) throws InvalidCipherTextException, JsonProcessingException { - - final GCMSIVBlockCipher cipher = new GCMSIVBlockCipher(aesEngine); - - cipher.init(false, createSecretKey(salt)); - - final byte[] ciphertext = base64Decoder.decode(ciphertextString); - - final byte[] plaintext = new byte[cipher.getOutputSize(ciphertext.length)]; - - final int outputLength = cipher.processBytes(ciphertext, 0, ciphertext.length, plaintext, 0); - - cipher.doFinal(plaintext, outputLength); - - cipher.reset(); - - final String encodedIdentifier = new String(plaintext, StandardCharsets.UTF_8); - - return identifierConverter.decode(encodedIdentifier); - - } - -} - - diff --git a/src/main/java/nl/ictu/service/ExchangeIdentifierService.java b/src/main/java/nl/ictu/service/ExchangeIdentifierService.java new file mode 100644 index 0000000..5031a35 --- /dev/null +++ b/src/main/java/nl/ictu/service/ExchangeIdentifierService.java @@ -0,0 +1,50 @@ +package nl.ictu.service; + +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.BSN; +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.ORGANISATION_PSEUDO; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierRequest; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierResponse; +import nl.ictu.service.exception.InvalidWsIdentifierRequestTypeException; +import nl.ictu.service.map.BsnPseudoMapper; +import nl.ictu.service.map.PseudoBsnMapper; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public final class ExchangeIdentifierService { + + private final BsnPseudoMapper bsnPseudoMapper; + private final PseudoBsnMapper pseudoBsnMapper; + + /** + * Processes the exchange of an identifier between different types based on specific mappings + * and returns the corresponding response. + * caller. + * @param wsExchangeIdentifierForIdentifierRequest The request object containing details of the + * identifier to be exchanged, including its + * value, type, recipient OIN, and recipient + * identifier type. + * @return A {@link WsExchangeIdentifierResponse} containing the exchanged identifier. Returns + * null if no appropriate mapping exists for the provided inputs. + */ + @SneakyThrows + public WsExchangeIdentifierResponse exchangeIdentifier( + final WsExchangeIdentifierRequest wsExchangeIdentifierForIdentifierRequest) { + + final var wsIdentifierRequest = wsExchangeIdentifierForIdentifierRequest.getIdentifier(); + final var recipientOIN = wsExchangeIdentifierForIdentifierRequest.getRecipientOIN(); + final var recipientIdentifierType = wsExchangeIdentifierForIdentifierRequest.getRecipientIdentifierType(); + if (BSN.equals(wsIdentifierRequest.getType()) && ORGANISATION_PSEUDO.equals( + recipientIdentifierType)) { + return bsnPseudoMapper.map(wsIdentifierRequest.getValue(), recipientOIN); + } else if (ORGANISATION_PSEUDO.equals(wsIdentifierRequest.getType()) && BSN.equals( + recipientIdentifierType)) { + return pseudoBsnMapper.map(wsIdentifierRequest.getValue(), recipientOIN); + } + throw new InvalidWsIdentifierRequestTypeException( + "Invalid WsIdentifierRequest type cannot be processed."); + } +} diff --git a/src/main/java/nl/ictu/service/ExchangeTokenService.java b/src/main/java/nl/ictu/service/ExchangeTokenService.java new file mode 100644 index 0000000..3ae27ff --- /dev/null +++ b/src/main/java/nl/ictu/service/ExchangeTokenService.java @@ -0,0 +1,63 @@ +package nl.ictu.service; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import nl.ictu.service.exception.InvalidOINException; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenRequest; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenResponse; +import nl.ictu.service.exception.InvalidWsIdentifierTokenException; +import nl.ictu.crypto.AesGcmCryptographer; +import nl.ictu.crypto.TokenCoder; +import nl.ictu.service.map.BsnTokenMapper; +import nl.ictu.service.map.OrganisationPseudoTokenMapper; +import nl.ictu.service.validate.OINValidator; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RequiredArgsConstructor +@RestController +public final class ExchangeTokenService { + + private final AesGcmCryptographer aesGcmCryptographer; + private final TokenCoder tokenCoder; + private final OINValidator oinValidator; + private final OrganisationPseudoTokenMapper organisationPseudoTokenMapper; + private final BsnTokenMapper bsnTokenMapper; + + + /** + * Exchanges a token for an identifier based on the provided request and caller OIN. + * + * @param callerOIN the originating organization's identification + * number used for validation + * @param wsExchangeTokenForIdentifierRequest the request containing the token and identifier + * type + * @return a WsExchangeTokenResponse containing the generated or resolved identifier + * @throws InvalidOINException if the caller OIN is not valid or does not match + * the token + * @throws InvalidWsIdentifierTokenException if the identifier type in the request is invalid or + * cannot be processed + */ + @SneakyThrows + public WsExchangeTokenResponse exchangeToken(final String callerOIN, + final WsExchangeTokenRequest wsExchangeTokenForIdentifierRequest) { + + final var encodedToken = aesGcmCryptographer.decrypt( + wsExchangeTokenForIdentifierRequest.getToken(), callerOIN); + final var token = tokenCoder.decode(encodedToken); + if (!oinValidator.isValid(callerOIN, token)) { + throw new InvalidOINException("CallerOIN and token are mismatched."); + } + switch (wsExchangeTokenForIdentifierRequest.getIdentifierType()) { + case BSN -> { + return bsnTokenMapper.map(token); + } + case ORGANISATION_PSEUDO -> { + return organisationPseudoTokenMapper.map(callerOIN, token); + } + default -> throw new InvalidWsIdentifierTokenException( + "Invalid identifier cannot be processed."); + } + } +} diff --git a/src/main/java/nl/ictu/service/GetTokenService.java b/src/main/java/nl/ictu/service/GetTokenService.java new file mode 100644 index 0000000..e9a1b6d --- /dev/null +++ b/src/main/java/nl/ictu/service/GetTokenService.java @@ -0,0 +1,46 @@ +package nl.ictu.service; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import nl.ictu.pseudoniemenservice.generated.server.model.WsGetTokenResponse; +import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifier; +import nl.ictu.service.exception.WsGetTokenProcessingException; +import nl.ictu.service.map.WsGetTokenResponseMapper; +import nl.ictu.service.map.WsIdentifierOinBsnMapper; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public final class GetTokenService { + + private final WsIdentifierOinBsnMapper wsIdentifierOinBsnMapper; + private final WsGetTokenResponseMapper wsGetTokenResponseMapper; + + /** + * Generates an encrypted token response based on the given recipient OIN and identifier. + * Validates the identifier type and maps it to the corresponding BSN before creating the + * encrypted token. + * + * @param recipientOIN the recipient's organizational identification number + * @param identifier the identifier containing value and type information + * @return a {@link WsGetTokenResponse} containing the encrypted token, or null if the + * identifier is invalid or BSN mapping fails + */ + @SneakyThrows + public WsGetTokenResponse getWsGetTokenResponse(final String recipientOIN, + final WsIdentifier identifier) { + + final var creationDate = System.currentTimeMillis(); + // check is callerOIN allowed to communicatie with sinkOIN + try { + final var bsn = wsIdentifierOinBsnMapper.map(identifier, recipientOIN); + return wsGetTokenResponseMapper.map(bsn, creationDate, recipientOIN); + } catch (Exception ex) { + final var exceptionMessage = ex.getMessage(); + log.warn(exceptionMessage); + throw new WsGetTokenProcessingException(exceptionMessage); + } + } +} diff --git a/src/main/java/nl/ictu/service/IdentifierConverter.java b/src/main/java/nl/ictu/service/IdentifierConverter.java deleted file mode 100644 index 0292ec4..0000000 --- a/src/main/java/nl/ictu/service/IdentifierConverter.java +++ /dev/null @@ -1,14 +0,0 @@ -package nl.ictu.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import nl.ictu.Identifier; - -import java.io.IOException; - -public interface IdentifierConverter { - - String encode(Identifier identifier) throws IOException; - - Identifier decode(String encodedIdentifier) throws JsonProcessingException; - -} diff --git a/src/main/java/nl/ictu/service/TokenConverter.java b/src/main/java/nl/ictu/service/TokenConverter.java deleted file mode 100644 index 8c8d539..0000000 --- a/src/main/java/nl/ictu/service/TokenConverter.java +++ /dev/null @@ -1,12 +0,0 @@ -package nl.ictu.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import nl.ictu.Token; - -import java.io.IOException; - -public interface TokenConverter { - String encode(Token token) throws IOException; - - Token decode(String encodedToken) throws JsonProcessingException; -} diff --git a/src/main/java/nl/ictu/service/TokenConverterImpl.java b/src/main/java/nl/ictu/service/TokenConverterImpl.java deleted file mode 100644 index 4d8b346..0000000 --- a/src/main/java/nl/ictu/service/TokenConverterImpl.java +++ /dev/null @@ -1,33 +0,0 @@ -package nl.ictu.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import nl.ictu.Token; -import org.springframework.aot.hint.annotation.RegisterReflectionForBinding; -import org.springframework.stereotype.Service; - -import java.io.IOException; -import java.io.StringWriter; - -@SuppressWarnings("DesignForExtension") -@Service -@RequiredArgsConstructor -@RegisterReflectionForBinding({Token.class}) -public class TokenConverterImpl implements TokenConverter { - - private final ObjectMapper objectMapper; - - @Override - public String encode(final Token token) throws IOException { - final StringWriter stringWriter = new StringWriter(); - objectMapper.writeValue(stringWriter, token); - return stringWriter.toString(); - } - - @Override - public Token decode(final String encodedToken) throws JsonProcessingException { - return objectMapper.readValue(encodedToken, Token.class); - } - -} diff --git a/src/main/java/nl/ictu/service/exception/IdentifierPrivateKeyException.java b/src/main/java/nl/ictu/service/exception/IdentifierPrivateKeyException.java new file mode 100644 index 0000000..660d8cd --- /dev/null +++ b/src/main/java/nl/ictu/service/exception/IdentifierPrivateKeyException.java @@ -0,0 +1,9 @@ +package nl.ictu.service.exception; + +public class IdentifierPrivateKeyException extends RuntimeException { + + public IdentifierPrivateKeyException(final String message) { + + super(message); + } +} diff --git a/src/main/java/nl/ictu/service/exception/InvalidOINException.java b/src/main/java/nl/ictu/service/exception/InvalidOINException.java new file mode 100644 index 0000000..a6d0132 --- /dev/null +++ b/src/main/java/nl/ictu/service/exception/InvalidOINException.java @@ -0,0 +1,9 @@ +package nl.ictu.service.exception; + +public class InvalidOINException extends RuntimeException { + + public InvalidOINException(final String message) { + + super(message); + } +} diff --git a/src/main/java/nl/ictu/service/exception/InvalidWsIdentifierRequestTypeException.java b/src/main/java/nl/ictu/service/exception/InvalidWsIdentifierRequestTypeException.java new file mode 100644 index 0000000..f56f23e --- /dev/null +++ b/src/main/java/nl/ictu/service/exception/InvalidWsIdentifierRequestTypeException.java @@ -0,0 +1,8 @@ +package nl.ictu.service.exception; + +public class InvalidWsIdentifierRequestTypeException extends RuntimeException { + public InvalidWsIdentifierRequestTypeException(final String message) { + + super(message); + } +} diff --git a/src/main/java/nl/ictu/service/exception/InvalidWsIdentifierTokenException.java b/src/main/java/nl/ictu/service/exception/InvalidWsIdentifierTokenException.java new file mode 100644 index 0000000..9e25a9d --- /dev/null +++ b/src/main/java/nl/ictu/service/exception/InvalidWsIdentifierTokenException.java @@ -0,0 +1,8 @@ +package nl.ictu.service.exception; + +public class InvalidWsIdentifierTokenException extends RuntimeException { + public InvalidWsIdentifierTokenException(final String message) { + + super(message); + } +} diff --git a/src/main/java/nl/ictu/service/exception/TokenPrivateKeyException.java b/src/main/java/nl/ictu/service/exception/TokenPrivateKeyException.java new file mode 100644 index 0000000..e844d27 --- /dev/null +++ b/src/main/java/nl/ictu/service/exception/TokenPrivateKeyException.java @@ -0,0 +1,9 @@ +package nl.ictu.service.exception; + +public class TokenPrivateKeyException extends RuntimeException { + + public TokenPrivateKeyException(final String message) { + + super(message); + } +} diff --git a/src/main/java/nl/ictu/service/exception/WsGetTokenProcessingException.java b/src/main/java/nl/ictu/service/exception/WsGetTokenProcessingException.java new file mode 100644 index 0000000..0c61f39 --- /dev/null +++ b/src/main/java/nl/ictu/service/exception/WsGetTokenProcessingException.java @@ -0,0 +1,8 @@ +package nl.ictu.service.exception; + +public class WsGetTokenProcessingException extends RuntimeException { + public WsGetTokenProcessingException(final String message) { + + super(message); + } +} diff --git a/src/main/java/nl/ictu/service/map/BsnPseudoMapper.java b/src/main/java/nl/ictu/service/map/BsnPseudoMapper.java new file mode 100644 index 0000000..4968cc0 --- /dev/null +++ b/src/main/java/nl/ictu/service/map/BsnPseudoMapper.java @@ -0,0 +1,44 @@ +package nl.ictu.service.map; + +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.ORGANISATION_PSEUDO; + +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import nl.ictu.model.Identifier; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierResponse; +import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifier; +import nl.ictu.crypto.AesGcmSivCryptographer; +import org.bouncycastle.crypto.InvalidCipherTextException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BsnPseudoMapper { + + public static final String V_1 = "v1"; + private final AesGcmSivCryptographer aesGcmSivCryptographer; + + /** + * Maps a given BSN (Burger Service Nummer) and OIN (Organisatie-identificatienummer) to a + * {@link WsExchangeIdentifierResponse} containing a pseudo-anonymous identifier. + * + * @param bsn the BSN to be encrypted and included in the identifier + * @param oin the OIN used as the salt for encryption + * @return a {@link WsExchangeIdentifierResponse} containing the pseudo-anonymous identifier + * @throws IOException if an I/O error occurs during the encryption process + * @throws InvalidCipherTextException if encryption fails due to invalid cipher text + */ + public WsExchangeIdentifierResponse map(final String bsn, final String oin) + throws IOException, InvalidCipherTextException { + + return WsExchangeIdentifierResponse.builder() + .identifier(WsIdentifier.builder() + .type(ORGANISATION_PSEUDO) + .value(aesGcmSivCryptographer.encrypt(Identifier.builder() + .version(V_1) + .bsn(bsn) + .build(), oin)) + .build()) + .build(); + } +} diff --git a/src/main/java/nl/ictu/service/map/BsnTokenMapper.java b/src/main/java/nl/ictu/service/map/BsnTokenMapper.java new file mode 100644 index 0000000..d71ddb2 --- /dev/null +++ b/src/main/java/nl/ictu/service/map/BsnTokenMapper.java @@ -0,0 +1,31 @@ +package nl.ictu.service.map; + +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.BSN; + +import lombok.RequiredArgsConstructor; +import nl.ictu.model.Token; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenResponse; +import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifier; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BsnTokenMapper { + + /** + * Maps a given Token to a WsExchangeTokenResponse. Populates the response with a WsIdentifier + * containing the BSN value from the provided token. + * + * @param token the Token object containing BSN and other data + * @return a WsExchangeTokenResponse containing the identifier with the BSN value + */ + public WsExchangeTokenResponse map(final Token token) { + + return WsExchangeTokenResponse.builder() + .identifier(WsIdentifier.builder() + .type(BSN) + .value(token.getBsn()) + .build()) + .build(); + } +} diff --git a/src/main/java/nl/ictu/service/map/EncryptedBsnMapper.java b/src/main/java/nl/ictu/service/map/EncryptedBsnMapper.java new file mode 100644 index 0000000..99f0d59 --- /dev/null +++ b/src/main/java/nl/ictu/service/map/EncryptedBsnMapper.java @@ -0,0 +1,25 @@ +package nl.ictu.service.map; + +import lombok.RequiredArgsConstructor; +import nl.ictu.crypto.AesGcmSivCryptographer; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class EncryptedBsnMapper { + + private final AesGcmSivCryptographer aesGcmSivCryptographer; + + /** + * Maps the encrypted business service number to its decrypted value using the given recipient OIN. + * + * @param bsnValue the encrypted business service number to be decrypted + * @param recipientOIN the recipient OIN key used for decryption + * @return the decrypted business service number + */ + public String map(final String bsnValue, final String recipientOIN) { + + final var decodedIdentifier = aesGcmSivCryptographer.decrypt(bsnValue, recipientOIN); + return decodedIdentifier.getBsn(); + } +} diff --git a/src/main/java/nl/ictu/service/map/OrganisationPseudoTokenMapper.java b/src/main/java/nl/ictu/service/map/OrganisationPseudoTokenMapper.java new file mode 100644 index 0000000..c9f33c0 --- /dev/null +++ b/src/main/java/nl/ictu/service/map/OrganisationPseudoTokenMapper.java @@ -0,0 +1,47 @@ +package nl.ictu.service.map; + +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.ORGANISATION_PSEUDO; + +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import nl.ictu.model.Identifier; +import nl.ictu.model.Token; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenResponse; +import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifier; +import nl.ictu.crypto.AesGcmSivCryptographer; +import org.bouncycastle.crypto.InvalidCipherTextException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OrganisationPseudoTokenMapper { + + public static final String V_1 = "v1"; + private final AesGcmSivCryptographer aesGcmSivCryptographer; + + + /** + * Maps the provided callerOIN and Token into a WsExchangeTokenResponse object. + * + * @param callerOIN the originating identification number of the caller + * @param token the Token object containing the required information such as BSN + * @return a WsExchangeTokenResponse containing an encrypted identifier + * @throws InvalidCipherTextException if there is an issue with the encryption process + * @throws IOException if there is an I/O error during encryption + */ + public WsExchangeTokenResponse map(final String callerOIN, + final Token token) throws InvalidCipherTextException, IOException { + + final var tokenIdentifier = Identifier.builder() + .version(V_1) + .bsn(token.getBsn()) + .build(); + final var encryptedIdentifier = aesGcmSivCryptographer.encrypt(tokenIdentifier, callerOIN); + return WsExchangeTokenResponse.builder() + .identifier(WsIdentifier.builder() + .type(ORGANISATION_PSEUDO) + .value(encryptedIdentifier) + .build()) + .build(); + } +} diff --git a/src/main/java/nl/ictu/service/map/PseudoBsnMapper.java b/src/main/java/nl/ictu/service/map/PseudoBsnMapper.java new file mode 100644 index 0000000..e1a1f68 --- /dev/null +++ b/src/main/java/nl/ictu/service/map/PseudoBsnMapper.java @@ -0,0 +1,43 @@ +package nl.ictu.service.map; + + +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.BSN; + +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierResponse; +import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifier; +import nl.ictu.crypto.AesGcmSivCryptographer; +import org.bouncycastle.crypto.InvalidCipherTextException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PseudoBsnMapper { + + private final AesGcmSivCryptographer aesGcmSivCryptographer; + + /** + * Maps a given pseudonym and organizational identification number (OIN) to a + * {@link WsExchangeIdentifierResponse}. The pseudonym is decrypted using the + * provided OIN to derive the corresponding BSN (Burger Service Nummer) value. + * + * @param pseudo the pseudonym string to be decrypted + * @param oin the organizational identification number used as a decryption key + * @return a {@link WsExchangeIdentifierResponse} containing the decrypted BSN encapsulated in a {@link WsIdentifier} + * @throws IOException if an I/O error occurs during the decryption process + * @throws InvalidCipherTextException if decryption fails due to invalid cipher text + */ + public WsExchangeIdentifierResponse map(final String pseudo, final String oin) + throws IOException, InvalidCipherTextException { + + return WsExchangeIdentifierResponse.builder() + + .identifier(WsIdentifier.builder() + .type(BSN) + .value(aesGcmSivCryptographer.decrypt(pseudo, oin).getBsn()) + .build()) + .build(); + } + +} diff --git a/src/main/java/nl/ictu/service/map/WsGetTokenResponseMapper.java b/src/main/java/nl/ictu/service/map/WsGetTokenResponseMapper.java new file mode 100644 index 0000000..12b983e --- /dev/null +++ b/src/main/java/nl/ictu/service/map/WsGetTokenResponseMapper.java @@ -0,0 +1,55 @@ +package nl.ictu.service.map; + +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import lombok.RequiredArgsConstructor; +import nl.ictu.crypto.AesGcmCryptographer; +import nl.ictu.crypto.TokenCoder; +import nl.ictu.model.Token; +import nl.ictu.pseudoniemenservice.generated.server.model.WsGetTokenResponse; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class WsGetTokenResponseMapper { + + public static final String V_1 = "v1"; + private final AesGcmCryptographer aesGcmCryptographer; + private final TokenCoder tokenCoder; + + /** + * Maps input parameters to a WsGetTokenResponse object. This involves creating a Token object + * with the given input parameters, encoding it, encrypting the encoded result, and incorporating + * it into a WsGetTokenResponse object. + * + * @param bsn the BSN value to be included in the Token object + * @param creationDate the creation date to be included in the Token object + * @param recipientOIN the recipient OIN value used in the Token object and for encryption + * @return a WsGetTokenResponse object containing the encrypted token + * @throws IOException if an I/O error occurs during the encoding process + * @throws IllegalBlockSizeException if the block size is invalid during the encryption process + * @throws BadPaddingException if an error occurs with padding during encryption + * @throws InvalidAlgorithmParameterException if algorithm parameters are invalid + * @throws InvalidKeyException if the encryption key is invalid + * @throws NoSuchAlgorithmException if the encryption algorithm is not available + * @throws NoSuchPaddingException if the padding scheme is not available + */ + public WsGetTokenResponse map(final String bsn, final long creationDate, + final String recipientOIN) + throws IOException, IllegalBlockSizeException, BadPaddingException, InvalidAlgorithmParameterException, InvalidKeyException, NoSuchAlgorithmException, NoSuchPaddingException { + + return WsGetTokenResponse.builder() + .token(aesGcmCryptographer.encrypt(tokenCoder.encode(Token.builder() + .version(V_1) + .bsn(bsn) + .creationDate(creationDate) + .recipientOIN(recipientOIN) + .build()), recipientOIN)) + .build(); + } +} diff --git a/src/main/java/nl/ictu/service/map/WsIdentifierOinBsnMapper.java b/src/main/java/nl/ictu/service/map/WsIdentifierOinBsnMapper.java new file mode 100644 index 0000000..3a1bf67 --- /dev/null +++ b/src/main/java/nl/ictu/service/map/WsIdentifierOinBsnMapper.java @@ -0,0 +1,37 @@ +package nl.ictu.service.map; + +import lombok.RequiredArgsConstructor; +import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifier; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class WsIdentifierOinBsnMapper { + + private final EncryptedBsnMapper encryptedBsnMapper; + + /** + * Maps a given {@link WsIdentifier} object to the corresponding representation based on its type. + * If the type is BSN, it returns the value as-is. If the type is ORGANISATION_PSEUDO, it applies + * a mapping operation using an encrypted BSN mapper. + * + * @param identifier the {@link WsIdentifier} to be mapped, containing the identifier's value and type + * @param recipientOIN the recipient organization identification number (OIN) used for mapping in case of ORGANISATION_PSEUDO type + * @return the mapped value of the identifier based on its type + * @throws IllegalArgumentException if the identifier type is unsupported + */ + public String map(final WsIdentifier identifier, final String recipientOIN) { + + final String bsnValue = identifier.getValue(); + switch (identifier.getType()) { + case BSN -> { + return bsnValue; + } + case ORGANISATION_PSEUDO -> { + return encryptedBsnMapper.map(bsnValue, recipientOIN); + } + default -> throw new IllegalArgumentException( + "Unsupported identifier type: " + identifier.getType()); + } + } +} diff --git a/src/main/java/nl/ictu/service/validate/OINValidator.java b/src/main/java/nl/ictu/service/validate/OINValidator.java new file mode 100644 index 0000000..ba37080 --- /dev/null +++ b/src/main/java/nl/ictu/service/validate/OINValidator.java @@ -0,0 +1,22 @@ +package nl.ictu.service.validate; + +import lombok.RequiredArgsConstructor; +import nl.ictu.model.Token; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OINValidator { + + /** + * Determines if the caller's OIN matches the recipient OIN from the token. + * + * @param callerOIN the OIN of the caller + * @param token the Token object containing recipient OIN + * @return true if the caller's OIN matches the recipient OIN, false otherwise + */ + public boolean isValid(final String callerOIN, final Token token) { + + return callerOIN.equals(token.getRecipientOIN()); + } +} diff --git a/src/main/java/nl/ictu/utils/AesUtility.java b/src/main/java/nl/ictu/utils/AesUtility.java new file mode 100644 index 0000000..e360f4f --- /dev/null +++ b/src/main/java/nl/ictu/utils/AesUtility.java @@ -0,0 +1,67 @@ +package nl.ictu.utils; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.GCMParameterSpec; +import lombok.experimental.UtilityClass; +import org.bouncycastle.crypto.MultiBlockCipher; +import org.bouncycastle.crypto.engines.AESEngine; + +@UtilityClass +public class AesUtility { + + public static final int IV_LENGTH = 12; + public static final int TAG_LENGTH = 128; + private static final String CIPHER = "AES/GCM/NoPadding"; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + /** + * Generates a random Initialization Vector (IV) for use with AES-GCM encryption. + * + * @return a GCMParameterSpec containing the randomly generated IV and the specified tag length + */ + // Method to generate a random Initialization Vector (IV) + public static GCMParameterSpec generateIV() { + + byte[] iv = new byte[IV_LENGTH]; // AES block size is 16 bytes + SECURE_RANDOM.nextBytes(iv); + return new GCMParameterSpec(TAG_LENGTH, iv); + } + + /** + * Creates a {@link GCMParameterSpec} instance using the specified initialization vector (IV). + * + * @param iv the byte array representing the initialization vector; must not be null, and its + * length should match the expected IV length for AES-GCM + * @return a GCMParameterSpec initialized with the provided IV and the predefined tag length + */ + public static GCMParameterSpec createIVfromValues(final byte[] iv) { + + return new GCMParameterSpec(TAG_LENGTH, iv); + } + + /** + * Creates and initializes a {@link Cipher} instance using the AES/GCM/NoPadding + * transformation. + * + * @return a Cipher instance initialized with the AES/GCM/NoPadding transformation + * @throws NoSuchPaddingException if the requested padding scheme is not available + * @throws NoSuchAlgorithmException if the AES algorithm in GCM mode is not available + */ + public static Cipher createCipher() throws NoSuchPaddingException, NoSuchAlgorithmException { + + return Cipher.getInstance(CIPHER); + } + + /** + * Returns an instance of {@link MultiBlockCipher} configured as an AES engine. + * + * @return a new instance of a {@link MultiBlockCipher} configured to use AES encryption + */ + public static MultiBlockCipher getAESEngine() { + + return AESEngine.newInstance(); + } +} diff --git a/src/main/java/nl/ictu/utils/Base64Wrapper.java b/src/main/java/nl/ictu/utils/Base64Wrapper.java new file mode 100644 index 0000000..3e00ebf --- /dev/null +++ b/src/main/java/nl/ictu/utils/Base64Wrapper.java @@ -0,0 +1,46 @@ +package nl.ictu.utils; + +import java.util.Base64; +import java.util.Base64.Decoder; +import java.util.Base64.Encoder; +import org.springframework.stereotype.Component; + +@Component +public final class Base64Wrapper { + + public static final Decoder DECODER = Base64.getDecoder(); + public static final Encoder ENCODER = Base64.getEncoder(); + + /** + * Decodes a Base64-encoded string into its original byte array representation. + * + * @param toDecode the Base64-encoded string to be decoded + * @return a byte array containing the decoded data + */ + public byte[] decode(final String toDecode) { + + return DECODER.decode(toDecode); + } + + /** + * Encodes a byte array into its Base64-encoded byte array representation. + * + * @param toEncode the byte array to be encoded + * @return a byte array containing the Base64-encoded representation of the input byte array + */ + public byte[] encode(final byte[] toEncode) { + + return ENCODER.encode(toEncode); + } + + /** + * Encodes the given byte array into a Base64-encoded string. + * + * @param toEncode the byte array to be encoded + * @return a String containing the Base64-encoded representation of the input byte array + */ + public String encodeToString(final byte[] toEncode) { + + return ENCODER.encodeToString(toEncode); + } +} diff --git a/src/main/java/nl/ictu/utils/ByteArrayUtil.java b/src/main/java/nl/ictu/utils/ByteArrayUtil.java new file mode 100644 index 0000000..ca2b6d3 --- /dev/null +++ b/src/main/java/nl/ictu/utils/ByteArrayUtil.java @@ -0,0 +1,23 @@ +package nl.ictu.utils; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class ByteArrayUtil { + + /** + * Concatenates two byte arrays into a single byte array. + * + * @param a the first byte array + * @param b the second byte array + * @return a new byte array containing all the elements of the first array followed by all the + * elements of the second array + */ + public static byte[] concat(final byte[] a, final byte[] b) { + + byte[] c = new byte[a.length + b.length]; + System.arraycopy(a, 0, c, 0, a.length); + System.arraycopy(b, 0, c, a.length, b.length); + return c; + } +} diff --git a/src/main/java/nl/ictu/utils/ByteArrayUtils.java b/src/main/java/nl/ictu/utils/ByteArrayUtils.java deleted file mode 100644 index 88222ea..0000000 --- a/src/main/java/nl/ictu/utils/ByteArrayUtils.java +++ /dev/null @@ -1,14 +0,0 @@ -package nl.ictu.utils; - -public final class ByteArrayUtils { - - private ByteArrayUtils() { - } - - public static byte[] concat(final byte[] a, final byte[] b) { - byte[] c = new byte[a.length + b.length]; - System.arraycopy(a, 0, c, 0, a.length); - System.arraycopy(b, 0, c, a.length, b.length); - return c; - } -} diff --git a/src/main/java/nl/ictu/utils/MessageDigestWrapper.java b/src/main/java/nl/ictu/utils/MessageDigestWrapper.java new file mode 100644 index 0000000..488b9e4 --- /dev/null +++ b/src/main/java/nl/ictu/utils/MessageDigestWrapper.java @@ -0,0 +1,22 @@ +package nl.ictu.utils; + +import java.security.MessageDigest; +import lombok.SneakyThrows; +import org.springframework.stereotype.Component; + +@Component +public final class MessageDigestWrapper { + + public static final String SHA_256 = "SHA-256"; + + /** + * Creates and returns a new instance of the MessageDigest configured for the SHA-256 algorithm. + * + * @return a MessageDigest instance initialized to use the SHA-256 algorithm + */ + @SneakyThrows + public MessageDigest getMessageDigestInstance() { + + return MessageDigest.getInstance(SHA_256); + } +} diff --git a/src/test/java/nl/ictu/TestingWebApplicationTests.java b/src/test/java/nl/ictu/TestingWebApplicationTests.java index 09211e0..cbb7dbc 100644 --- a/src/test/java/nl/ictu/TestingWebApplicationTests.java +++ b/src/test/java/nl/ictu/TestingWebApplicationTests.java @@ -1,7 +1,14 @@ package nl.ictu; +import static java.util.Map.of; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.data.MapEntry.entry; + +import java.util.List; +import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -11,17 +18,9 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; import org.springframework.test.context.ActiveProfiles; import org.springframework.util.CollectionUtils; -import java.util.List; -import java.util.Map; - -import static java.util.Map.of; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.data.MapEntry.entry; - @Slf4j @ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -29,60 +28,58 @@ class TestingWebApplicationTests { @Autowired private Environment environment; - @Autowired private TestRestTemplate restTemplate; @Test - public void contextLoads() { - } - - @Test - public void testActuatorHealthEndpoint() { - + @DisplayName(""" + Given the Spring Boot application is running with actuator enabled + When accessing the /actuator/health endpoint + Then the response should contain a status of 'UP' + """) + void testActuatorHealthEndpoint() { final int actuatorPort = environment.getProperty("local.management.port", Integer.class); - assertThat( - restTemplate - .getForObject("http://localhost:" + actuatorPort + "/actuator/health", String.class) + restTemplate.getForObject("http://localhost:" + actuatorPort + "/actuator/health", + String.class) ).contains("{\"status\":\"UP\"}"); } @Test - public void testGetAtokenExchangeForBSN() { - + @DisplayName(""" + Given a request to get a token with a BSN identifier + When sending the request to /v1/getToken + Then the response should include a token + And the token can be used to exchange for the identifier type BSN + """) + void testGetAtokenExchangeForBSN() { // get a token - - final Map getTokenBody = Map.of("recipientOIN", "54321543215432154321", "identifier", Map.of("type", "BSN", "value", "012345679")); - - final HttpEntity httpEntityGetToken = new HttpEntity(getTokenBody, new HttpHeaders(CollectionUtils.toMultiValueMap(of("callerOIN", List.of("0912345012345012345012345"))))); - - final ResponseEntity tokenExchange = restTemplate.exchange("/v1/getToken", HttpMethod.POST, httpEntityGetToken, Map.class); - + final var getTokenBody = Map.of("recipientOIN", "54321543215432154321", "identifier", + Map.of("type", "BSN", "value", "012345679")); + final var httpEntityGetToken = new HttpEntity<>(getTokenBody, + new HttpHeaders(CollectionUtils.toMultiValueMap( + of("callerOIN", List.of("0912345012345012345012345"))))); + final var tokenExchange = restTemplate.exchange("/v1/getToken", HttpMethod.POST, + httpEntityGetToken, Map.class); assertThat(tokenExchange.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(tokenExchange) - .extracting("body") - .asInstanceOf(InstanceOfAssertFactories.map(String.class, Void.class)) - .containsKey("token"); + .extracting("body") + .asInstanceOf(InstanceOfAssertFactories.map(String.class, Void.class)) + .containsKey("token"); // change token for identifier - - final String token = (String) tokenExchange.getBody().get("token"); - - final Map exchangeTokenBody = Map.of("token", token, "identifierType", "BSN"); - - final HttpEntity httpEntityExchangeToken = new HttpEntity(exchangeTokenBody, new HttpHeaders(CollectionUtils.toMultiValueMap(of("callerOIN", List.of("54321543215432154321"))))); - - final ResponseEntity identifierExchange = restTemplate.exchange("/v1/exchangeToken", HttpMethod.POST, httpEntityExchangeToken, Map.class); - + final var token = (String) tokenExchange.getBody().get("token"); + final var exchangeTokenBody = Map.of("token", token, "identifierType", "BSN"); + final var httpEntityExchangeToken = new HttpEntity<>(exchangeTokenBody, + new HttpHeaders(CollectionUtils.toMultiValueMap( + of("callerOIN", List.of("54321543215432154321"))))); + final var identifierExchange = restTemplate.exchange("/v1/exchangeToken", HttpMethod.POST, + httpEntityExchangeToken, + Map.class); assertThat(identifierExchange.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(identifierExchange) - .extracting("body") - .asInstanceOf(InstanceOfAssertFactories.map(String.class, Map.class)) - .containsExactly(entry("identifier", Map.of("type", "BSN", "value", "012345679"))); - + .extracting("body") + .asInstanceOf(InstanceOfAssertFactories.map(String.class, Map.class)) + .containsExactly(entry("identifier", Map.of("type", "BSN", "value", "012345679"))); } - -} \ No newline at end of file +} diff --git a/src/test/java/nl/ictu/configuration/PseudoniemenServicePropertiesTest.java b/src/test/java/nl/ictu/configuration/PseudoniemenServicePropertiesTest.java new file mode 100644 index 0000000..19a73ae --- /dev/null +++ b/src/test/java/nl/ictu/configuration/PseudoniemenServicePropertiesTest.java @@ -0,0 +1,55 @@ +package nl.ictu.configuration; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import nl.ictu.service.exception.IdentifierPrivateKeyException; +import nl.ictu.service.exception.TokenPrivateKeyException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class PseudoniemenServicePropertiesTest { + + @Test + @DisplayName(""" + Given an empty token private key + When validating + When TokenPrivateKeyException is thrown + """) + void validate_WhenTokenPrivateKeyIsEmpty_ThrowsTokenPrivateKeyException() { + // GIVEN + final var props = new PseudoniemenServiceProperties() + .setTokenPrivateKey("") + .setIdentifierPrivateKey("someIdentifierKey"); + // WHEN & THEN + assertThrows(TokenPrivateKeyException.class, props::validate); + } + + @Test + @DisplayName(""" + Given an empty identifier private key + When validating, then IdentifierPrivateKeyException is thrown + """) + void validate_WhenIdentifierPrivateKeyIsEmpty_ThrowsIdentifierPrivateKeyException() { + // GIVEN + final var props = new PseudoniemenServiceProperties() + .setTokenPrivateKey("someTokenKey") + .setIdentifierPrivateKey(""); + // WHEN & THEN + assertThrows(IdentifierPrivateKeyException.class, props::validate); + } + + @Test + @DisplayName(""" + Given both keys are set when validating + Then no exception is thrown + """) + void validate_WhenBothKeysAreSet_NoExceptionIsThrown() { + // GIVEN + final var props = new PseudoniemenServiceProperties() + .setTokenPrivateKey("someTokenKey") + .setIdentifierPrivateKey("someIdentifierKey"); + // WHEN & THEN + assertDoesNotThrow(props::validate); + } +} diff --git a/src/test/java/nl/ictu/controller/GlobalExceptionHandlerTest.java b/src/test/java/nl/ictu/controller/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..9a3eccb --- /dev/null +++ b/src/test/java/nl/ictu/controller/GlobalExceptionHandlerTest.java @@ -0,0 +1,90 @@ +package nl.ictu.controller; + +import static org.mockito.Mockito.doThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import nl.ictu.controller.stub.StubController; +import nl.ictu.controller.stub.StubService; +import nl.ictu.service.exception.IdentifierPrivateKeyException; +import nl.ictu.service.exception.InvalidOINException; +import nl.ictu.service.exception.InvalidWsIdentifierRequestTypeException; +import nl.ictu.service.exception.InvalidWsIdentifierTokenException; +import nl.ictu.service.exception.TokenPrivateKeyException; +import nl.ictu.service.exception.WsGetTokenProcessingException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +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.test.web.servlet.MockMvc; + +@WebMvcTest({GlobalExceptionHandler.class, StubController.class}) +class GlobalExceptionHandlerTest { + + public static final String SERVICE_ERROR_MESSAGE = "Service error"; + @Autowired + private MockMvc mockMvc; + @MockBean + private StubService stubService; + + // Test for handleGenericException + @Test + @DisplayName(""" + Given an invalid endpoint + When a GET request is made + Then an internal server error is returned with an appropriate message + """) + void handleGenericException_ShouldReturnInternalServerErrorWithMessage() throws Exception { + + mockMvc.perform(get("/non-existent-endpoint")) // Assuming no controller is mapped to this + .andExpect(status().isInternalServerError()) + .andExpect(content().contentType("text/plain;charset=UTF-8")) + .andExpect(content().string( + "An unexpected error occurred: No static resource non-existent-endpoint.")); + } + + @Test + @DisplayName(""" + Given a stubbed controller and service + When the service throws various exceptions + Then the system responds with UNPROCESSABLE_ENTITY and the exception message + """) + void exchangeToken_ShouldReturnUnprocessableEntity() { + // GIVEN: a stubbed controller and service + // WHEN: the service throws an exception + final var exceptions = List.of( + new IdentifierPrivateKeyException(SERVICE_ERROR_MESSAGE), + new InvalidWsIdentifierRequestTypeException(SERVICE_ERROR_MESSAGE), + new InvalidWsIdentifierTokenException(SERVICE_ERROR_MESSAGE), + new TokenPrivateKeyException(SERVICE_ERROR_MESSAGE), + new WsGetTokenProcessingException(SERVICE_ERROR_MESSAGE), + new InvalidOINException(SERVICE_ERROR_MESSAGE) + ); + exceptions.forEach(this::testExceptionHandlingBehavior); + } + + /** + * Tests the behavior of exception handling by simulating a scenario where the stub service + * throws the given RuntimeException. This test verifies that when an exception is thrown by the + * service, the system responds with the appropriate HTTP status code. + * + * @param ex the RuntimeException to be thrown by the stub service during the test + */ + private void testExceptionHandlingBehavior(final Exception ex) { + + try { + doThrow(ex) + .when(stubService) + .throwAStubbedException(); + // THEN: perform the POST request + mockMvc.perform(get("/stubby")) + .andExpect(status().isUnprocessableEntity()) + .andExpect(content().string(ex.getMessage())); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/src/test/java/nl/ictu/controller/IndexControllerTest.java b/src/test/java/nl/ictu/controller/IndexControllerTest.java new file mode 100644 index 0000000..ad11828 --- /dev/null +++ b/src/test/java/nl/ictu/controller/IndexControllerTest.java @@ -0,0 +1,30 @@ +package nl.ictu.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@ExtendWith(SpringExtension.class) +class IndexControllerTest { + + private final IndexController controller = new IndexController(); + private final MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + @Test + @DisplayName(""" + Given a request to the root endpoint + When performing a GET request + Then the response redirects to Swagger UI + """) + void testRedirectToSwaggerUi() throws Exception { + // WHEN & THEN + mockMvc.perform( + org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/")) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) + .andExpect(MockMvcResultMatchers.redirectedUrl("/swagger-ui/index.html")); + } +} diff --git a/src/test/java/nl/ictu/controller/stub/StubController.java b/src/test/java/nl/ictu/controller/stub/StubController.java new file mode 100644 index 0000000..ed61620 --- /dev/null +++ b/src/test/java/nl/ictu/controller/stub/StubController.java @@ -0,0 +1,28 @@ +package nl.ictu.controller.stub; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +/** + * This controller contains endpoints for interacting with stubbed services. It demonstrates a + * simple REST controller setup with a GET endpoint.This stubbed controller is used to test behavior + * exception handling in congestion with the GlobalExceptionHandler class + */ +@RestController +public class StubController { + + @Autowired + public StubService service; + + @RequestMapping( + method = RequestMethod.GET, + value = "/stubby") + public ResponseEntity get() { + + service.throwAStubbedException(); + return ResponseEntity.ok("stubbed body"); + } +} diff --git a/src/test/java/nl/ictu/controller/stub/StubService.java b/src/test/java/nl/ictu/controller/stub/StubService.java new file mode 100644 index 0000000..c3db35f --- /dev/null +++ b/src/test/java/nl/ictu/controller/stub/StubService.java @@ -0,0 +1,11 @@ +package nl.ictu.controller.stub; + +import org.springframework.stereotype.Service; + +@Service +public class StubService { + + public void throwAStubbedException() { + // mockito will be used to throw a mocked exception from this stubb + } +} diff --git a/src/test/java/nl/ictu/controller/v1/ExchangeIdentifierControllerTest.java b/src/test/java/nl/ictu/controller/v1/ExchangeIdentifierControllerTest.java new file mode 100644 index 0000000..53770ee --- /dev/null +++ b/src/test/java/nl/ictu/controller/v1/ExchangeIdentifierControllerTest.java @@ -0,0 +1,64 @@ +package nl.ictu.controller.v1; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierRequest; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierResponse; +import nl.ictu.service.ExchangeIdentifierService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +@ExtendWith(MockitoExtension.class) +class ExchangeIdentifierControllerTest { + + @Mock + private ExchangeIdentifierService service; + @InjectMocks + private ExchangeIdentifierController controller; + + @Test + @DisplayName(""" + Given a valid request and service response + When calling exchangeIdentifier() + Then it returns 200 OK with the expected response + """) + void testExchangeIdentifier_Success() { + // GIVEN + final var callerOIN = "123456789"; + final var request = new WsExchangeIdentifierRequest(); + final var expectedResponse = new WsExchangeIdentifierResponse(); + when(service.exchangeIdentifier(request)).thenReturn(expectedResponse); + // WHEN + final var response = controller.exchangeIdentifier(callerOIN, request); + // THEN + assertEquals(ResponseEntity.ok(expectedResponse), response); + verify(service).exchangeIdentifier(request); // Ensure service method is called + } + + @Test + @DisplayName(""" + Given a valid request and service throws an exception + When calling exchangeIdentifier() + Then it throws the same exception with the correct message + """) + void testExchangeIdentifier_ServiceThrowsException() { + // GIVEN + final var callerOIN = "123456789"; + final var request = new WsExchangeIdentifierRequest(); + final var exception = new RuntimeException("Service error"); + when(service.exchangeIdentifier(request)).thenThrow(exception); + // WHEN & THEN + final var thrownException = assertThrows(RuntimeException.class, + () -> controller.exchangeIdentifier(callerOIN, request)); + assertEquals("Service error", thrownException.getMessage()); + verify(service).exchangeIdentifier(request); // Ensure service method is called + } +} diff --git a/src/test/java/nl/ictu/controller/v1/ExchangeTokenControllerTest.java b/src/test/java/nl/ictu/controller/v1/ExchangeTokenControllerTest.java new file mode 100644 index 0000000..be523e8 --- /dev/null +++ b/src/test/java/nl/ictu/controller/v1/ExchangeTokenControllerTest.java @@ -0,0 +1,91 @@ +package nl.ictu.controller.v1; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenRequest; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenResponse; +import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifier; +import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes; +import nl.ictu.service.ExchangeTokenService; +import nl.ictu.service.exception.InvalidOINException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +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; + +@WebMvcTest(ExchangeTokenController.class) +class ExchangeTokenControllerTest { + + @Autowired + private MockMvc mockMvc; + @MockBean + private ExchangeTokenService exchangeTokenService; + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName(""" + Given a valid token and identifier type + When calling exchangeToken() + Then it returns 200 OK with the expected identifier in the response + """) + void exchangeToken_ShouldReturnOk() throws Exception { + // GIVEN: a request payload + final var requestPayload = WsExchangeTokenRequest.builder() + .token("testToken") + .identifierType(WsIdentifierTypes.BSN) + .build(); + // AND: a mock service response + final var responsePayload = new WsExchangeTokenResponse(); + responsePayload.setIdentifier(WsIdentifier.builder() + .type(WsIdentifierTypes.BSN) + .value("convertedIdentifier") + .build()); + // WHEN: the service is called, return the response payload + when(exchangeTokenService.exchangeToken(eq("TEST_OIN"), any(WsExchangeTokenRequest.class))) + .thenReturn(responsePayload); + // THEN: perform the POST request + mockMvc.perform(post("/v1/exchangeToken") + .header("callerOIN", "TEST_OIN") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestPayload))) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.identifier.value").value("convertedIdentifier")) + .andExpect(jsonPath("$.identifier.type").value("BSN")); + } + + @Test + @DisplayName(""" + Given an invalid token and identifier type + When calling exchangeToken() + Then it returns 422 UNPROCESSABLE_ENTITY with the appropriate error + """) + void exchangeToken_ShouldReturnUnprocessableEntity() throws Exception { + // GIVEN: a request payload + final var requestPayload = WsExchangeTokenRequest.builder() + .token("testToken").identifierType(WsIdentifierTypes.ORGANISATION_PSEUDO) + .build(); + // WHEN: the service throws an exception + doThrow(new InvalidOINException("Service error")) + .when(exchangeTokenService) + .exchangeToken(eq("FAIL_OIN"), any(WsExchangeTokenRequest.class)); + // THEN: perform the POST request + mockMvc.perform(post("/v1/exchangeToken") + .header("callerOIN", "FAIL_OIN") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestPayload))) + .andExpect(status().isUnprocessableEntity()); + } +} diff --git a/src/test/java/nl/ictu/service/ExchangeIdentifierServiceTest.java b/src/test/java/nl/ictu/service/ExchangeIdentifierServiceTest.java new file mode 100644 index 0000000..8c1e5ef --- /dev/null +++ b/src/test/java/nl/ictu/service/ExchangeIdentifierServiceTest.java @@ -0,0 +1,131 @@ +package nl.ictu.service; + +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.BSN; +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.ORGANISATION_PSEUDO; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierRequest; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierResponse; +import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifier; +import nl.ictu.service.exception.InvalidWsIdentifierRequestTypeException; +import nl.ictu.service.map.BsnPseudoMapper; +import nl.ictu.service.map.PseudoBsnMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link ExchangeIdentifierService}. + */ +@ExtendWith(MockitoExtension.class) +class ExchangeIdentifierServiceTest { + + @Mock + private BsnPseudoMapper bsnPseudoMapper; + @Mock + private PseudoBsnMapper pseudoBsnMapper; + @InjectMocks + private ExchangeIdentifierService exchangeIdentifierService; + + @Test + @DisplayName(""" + Given a BSN identifier and recipientIdentifierType ORGANISATION_PSEUDO + When exchangeIdentifier() is called + Then it should return a response with ORGANISATION_PSEUDO type and encrypted value + """) + void testExchangeIdentifier_BsnToOrgPseudo() throws Exception { + // GIVEN + var request = WsExchangeIdentifierRequest.builder() + .identifier(WsIdentifier.builder() + .type(BSN) + .value("123456789") + .build()) + .recipientOIN("TEST_OIN") + .recipientIdentifierType(ORGANISATION_PSEUDO) + .build(); + // We mock BsnPseudoMapper to return a WsExchangeIdentifierResponse + final var mockedResponse = WsExchangeIdentifierResponse.builder() + .identifier(WsIdentifier.builder() + .type(ORGANISATION_PSEUDO) + .value("encryptedValue") + .build()) + .build(); + when(bsnPseudoMapper.map("123456789", "TEST_OIN")).thenReturn(mockedResponse); + // WHEN + WsExchangeIdentifierResponse actualResponse = + exchangeIdentifierService.exchangeIdentifier(request); + // THEN + assertNotNull(actualResponse); + assertNotNull(actualResponse.getIdentifier()); + assertEquals(ORGANISATION_PSEUDO, + actualResponse.getIdentifier().getType()); + assertEquals("encryptedValue", + actualResponse.getIdentifier().getValue()); + } + + @Test + @DisplayName(""" + Given an ORGANISATION_PSEUDO identifier and recipientIdentifierType BSN + When exchangeIdentifier() is called + Then it should return a response with BSN type and decrypted value + """) + void testExchangeIdentifier_OrgPseudoToBsn() throws Exception { + // GIVEN + final var request = WsExchangeIdentifierRequest.builder() + .identifier(WsIdentifier.builder() + .type(ORGANISATION_PSEUDO) + .value("somePseudo") + .build()) + .recipientOIN("TEST_OIN") + .recipientIdentifierType(BSN) + .build(); + // We mock PseudoBsnMapper to return a WsExchangeIdentifierResponse + final var mockedResponse = WsExchangeIdentifierResponse.builder() + .identifier(WsIdentifier.builder() + .type(BSN) + .value("decryptedBsn") + .build()) + .build(); + when(pseudoBsnMapper.map("somePseudo", "TEST_OIN")).thenReturn(mockedResponse); + // WHEN + WsExchangeIdentifierResponse actualResponse = + exchangeIdentifierService.exchangeIdentifier(request); + // THEN + assertNotNull(actualResponse); + assertNotNull(actualResponse.getIdentifier()); + assertEquals(BSN, + actualResponse.getIdentifier().getType()); + assertEquals("decryptedBsn", + actualResponse.getIdentifier().getValue()); + } + + @Test + @DisplayName(""" + Given a request with unsupported identifier mapping (BSN -> BSN or ORG_PSEUDO -> ORG_PSEUDO) + When exchangeIdentifier() is called + Then it should throw InvalidWsIdentifierRequestTypeException + """) + void testExchangeIdentifier_UnsupportedMapping_ThrowsException() { + // GIVEN + final var request = WsExchangeIdentifierRequest.builder() + // Let's say we do something like BSN -> BSN or ORG_PSEUDO -> ORG_PSEUDO + .identifier(WsIdentifier.builder() + .type(BSN) + .value("12345") + .build()) + .recipientOIN("TEST_OIN") + .recipientIdentifierType(BSN) + .build(); + // WHEN & THEN + assertThrows( + InvalidWsIdentifierRequestTypeException.class, + () -> exchangeIdentifierService.exchangeIdentifier(request) + ); + } +} diff --git a/src/test/java/nl/ictu/service/ExchangeTokenServiceTest.java b/src/test/java/nl/ictu/service/ExchangeTokenServiceTest.java new file mode 100644 index 0000000..7b564a9 --- /dev/null +++ b/src/test/java/nl/ictu/service/ExchangeTokenServiceTest.java @@ -0,0 +1,139 @@ +package nl.ictu.service; + +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.BSN; +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.ORGANISATION_PSEUDO; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import nl.ictu.crypto.AesGcmCryptographer; +import nl.ictu.crypto.TokenCoder; +import nl.ictu.model.Token; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenRequest; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenResponse; +import nl.ictu.service.exception.InvalidOINException; +import nl.ictu.service.map.BsnTokenMapper; +import nl.ictu.service.map.OrganisationPseudoTokenMapper; +import nl.ictu.service.validate.OINValidator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ExchangeTokenServiceTest { + + private final String callerOIN = "123456789"; + private final String encryptedToken = "encryptedTokenValue"; + private final String decodedToken = "decodedTokenValue"; + + @Mock + private AesGcmCryptographer aesGcmCryptographer; + @Mock + private TokenCoder tokenCoder; + @Mock + private OINValidator oinValidator; + @Mock + private OrganisationPseudoTokenMapper organisationPseudoTokenMapper; + @Mock + private BsnTokenMapper bsnTokenMapper; + @InjectMocks + private ExchangeTokenService exchangeTokenService; + + private Token mockToken; + + @BeforeEach + void setUp() { + + mockToken = Token.builder() + .recipientOIN(callerOIN) + .bsn("987654321") + .build(); + } + + @Test + @DisplayName(""" + Given a BSN identifier + When exchangeToken() is called + Then it should return a valid response mapped by BsnTokenMapper + """) + void testExchangeToken_BsnIdentifier() throws Exception { + // GIVEN + final var request = WsExchangeTokenRequest.builder() + .token(encryptedToken) + .identifierType(BSN) + .build(); + // Stubbing dependencies + when(aesGcmCryptographer.decrypt(encryptedToken, callerOIN)).thenReturn(decodedToken); + when(tokenCoder.decode(decodedToken)).thenReturn(mockToken); + when(oinValidator.isValid(callerOIN, mockToken)).thenReturn(true); + var expectedResponse = mock(WsExchangeTokenResponse.class); + when(bsnTokenMapper.map(mockToken)).thenReturn(expectedResponse); + // WHEN + final var actualResponse = exchangeTokenService.exchangeToken(callerOIN, request); + // THEN + verify(aesGcmCryptographer).decrypt(encryptedToken, callerOIN); + verify(tokenCoder).decode(decodedToken); + verify(oinValidator).isValid(callerOIN, mockToken); + verify(bsnTokenMapper).map(mockToken); + assertEquals(expectedResponse, actualResponse); + } + + @Test + @DisplayName(""" + Given an ORGANISATION_PSEUDO identifier + When exchangeToken() is called + Then it should return a valid response mapped by OrganisationPseudoTokenMapper + """) + void testExchangeToken_OrganisationPseudoIdentifier() throws Exception { + // GIVEN + final var request = WsExchangeTokenRequest.builder() + .token(encryptedToken) + .identifierType(ORGANISATION_PSEUDO) + .build(); + // Stubbing dependencies + when(aesGcmCryptographer.decrypt(encryptedToken, callerOIN)).thenReturn(decodedToken); + when(tokenCoder.decode(decodedToken)).thenReturn(mockToken); + when(oinValidator.isValid(callerOIN, mockToken)).thenReturn(true); + final var expectedResponse = mock(WsExchangeTokenResponse.class); + when(organisationPseudoTokenMapper.map(callerOIN, mockToken)).thenReturn(expectedResponse); + // WHEN + final var actualResponse = exchangeTokenService.exchangeToken(callerOIN, request); + // THEN + verify(aesGcmCryptographer).decrypt(encryptedToken, callerOIN); + verify(tokenCoder).decode(decodedToken); + verify(oinValidator).isValid(callerOIN, mockToken); + verify(organisationPseudoTokenMapper).map(callerOIN, mockToken); + assertEquals(expectedResponse, actualResponse); + } + + @Test + @DisplayName(""" + Given an invalid OIN + When exchangeToken() is called + Then it should throw InvalidOINException + """) + void testExchangeToken_InvalidOIN() throws Exception { + // GIVEN + final var request = WsExchangeTokenRequest.builder() + .token(encryptedToken) + .identifierType(BSN) + .build(); + // Stubbing dependencies + when(aesGcmCryptographer.decrypt(encryptedToken, callerOIN)).thenReturn(decodedToken); + when(tokenCoder.decode(decodedToken)).thenReturn(mockToken); + when(oinValidator.isValid(callerOIN, mockToken)).thenReturn(false); // Invalid OIN + // WHEN & THEN + assertThrows(InvalidOINException.class, () -> exchangeTokenService.exchangeToken(callerOIN, request)); + verify(aesGcmCryptographer).decrypt(encryptedToken, callerOIN); + verify(tokenCoder).decode(decodedToken); + verify(oinValidator).isValid(callerOIN, mockToken); + verifyNoInteractions(bsnTokenMapper, organisationPseudoTokenMapper); + } +} diff --git a/src/test/java/nl/ictu/service/GetTokenServiceTest.java b/src/test/java/nl/ictu/service/GetTokenServiceTest.java new file mode 100644 index 0000000..87ef602 --- /dev/null +++ b/src/test/java/nl/ictu/service/GetTokenServiceTest.java @@ -0,0 +1,100 @@ +package nl.ictu.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.anyLong; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import nl.ictu.pseudoniemenservice.generated.server.model.WsGetTokenResponse; +import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifier; +import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes; +import nl.ictu.service.exception.WsGetTokenProcessingException; +import nl.ictu.service.map.WsGetTokenResponseMapper; +import nl.ictu.service.map.WsIdentifierOinBsnMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GetTokenServiceTest { + + private final String recipientOIN = "123456789"; + private final String bsn = "987654321"; + + @Mock + private WsIdentifierOinBsnMapper wsIdentifierOinBsnMapper; + + @Mock + private WsGetTokenResponseMapper wsGetTokenResponseMapper; + + @InjectMocks + private GetTokenService getTokenService; + + @BeforeEach + void setUp() { + // Initialize common test data if needed + } + + @Test + @DisplayName(""" + Given a valid identifier of type BSN + When getWsGetTokenResponse() is called + Then it should return a valid response + """) + void testGetWsGetTokenResponse_ValidInput() throws Exception { + // GIVEN + final var identifier = WsIdentifier.builder() + .type(WsIdentifierTypes.BSN) + .value(bsn) + .build(); + final var expectedResponse = mock(WsGetTokenResponse.class); + + // Stubbing dependencies + when(wsIdentifierOinBsnMapper.map(identifier, recipientOIN)).thenReturn(bsn); + when(wsGetTokenResponseMapper.map(eq(bsn), anyLong(), eq(recipientOIN))).thenReturn(expectedResponse); + + // WHEN + final var actualResponse = getTokenService.getWsGetTokenResponse(recipientOIN, identifier); + + // THEN + verify(wsIdentifierOinBsnMapper).map(identifier, recipientOIN); + verify(wsGetTokenResponseMapper).map(eq(bsn), anyLong(), eq(recipientOIN)); + assertEquals(expectedResponse, actualResponse); + } + + @Test + @DisplayName(""" + Given an unexpected error during processing + When getWsGetTokenResponse() is called + Then it should throw WsGetTokenProcessingException with the correct message + """) + void testGetWsGetTokenResponse_UnexpectedError() { + // GIVEN + final var identifier = WsIdentifier.builder() + .type(WsIdentifierTypes.BSN) + .value(bsn) + .build(); + final var exceptionMessage = "Unexpected processing error"; + + // Stubbing dependencies + when(wsIdentifierOinBsnMapper.map(identifier, recipientOIN)) + .thenThrow(new RuntimeException(exceptionMessage)); + + // WHEN & THEN + final var exception = assertThrows(WsGetTokenProcessingException.class, + () -> getTokenService.getWsGetTokenResponse(recipientOIN, identifier)); + + // Assert exception message + assertEquals(exceptionMessage, exception.getMessage()); + verify(wsIdentifierOinBsnMapper).map(identifier, recipientOIN); + verifyNoInteractions(wsGetTokenResponseMapper); + } +} diff --git a/src/test/java/nl/ictu/service/TestAesGcmCryptographer.java b/src/test/java/nl/ictu/service/TestAesGcmCryptographer.java index b1e691a..2bc0fcf 100644 --- a/src/test/java/nl/ictu/service/TestAesGcmCryptographer.java +++ b/src/test/java/nl/ictu/service/TestAesGcmCryptographer.java @@ -1,58 +1,72 @@ package nl.ictu.service; +import static org.assertj.core.api.Assertions.assertThat; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; import lombok.extern.slf4j.Slf4j; import nl.ictu.configuration.PseudoniemenServiceProperties; +import nl.ictu.crypto.AesGcmCryptographer; +import nl.ictu.utils.Base64Wrapper; +import nl.ictu.utils.MessageDigestWrapper; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.context.ActiveProfiles; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; - /** - * Class for tesing {@link AesGcmCryptographerImpl} + * Class for testing {@link AesGcmCryptographer} */ - @Slf4j @ActiveProfiles("test") -public class TestAesGcmCryptographer { - - private final AesGcmCryptographer aesGcmCryptographer = new AesGcmCryptographerImpl(new PseudoniemenServiceProperties().setTokenPrivateKey("bFUyS1FRTVpON0pCSFFRRGdtSllSeUQ1MlRna2txVmI=")); - - private final Set testStrings = new HashSet<>(Arrays.asList("a", "bb", "dsv", "ghad", "dhaht", "uDg5Av", "d93fdvv", "dj83hzHo", "38iKawKv9", "dk(gkzm)Mh", "gjk)s3$g9cQ")); +class TestAesGcmCryptographer { + + private final AesGcmCryptographer aesGcmCryptographer = new AesGcmCryptographer( + new Base64Wrapper(), + new MessageDigestWrapper(), + new PseudoniemenServiceProperties().setTokenPrivateKey( + "bFUyS1FRTVpON0pCSFFRRGdtSllSeUQ1MlRna2txVmI=") + ); + private final Set testStrings = new HashSet<>( + Arrays.asList("a", "bb", "dsv", "ghad", "dhaht", "uDg5Av", "d93fdvv", "dj83hzHo", + "38iKawKv9", "dk(gkzm)Mh", "gjk)s3$g9cQ")); @Test - public void testEncyptDecryptForDifferentStringLengths() { + @DisplayName(""" + Given a set of test strings + When encrypting and decrypting each string with a specific key + Then the decrypted string should be equal to the original plain string + """) + void testEncyptDecryptForDifferentStringLengths() { testStrings.forEach(plain -> { - try { - final String crypted = aesGcmCryptographer.encrypt(plain, "helloHowAreyo12345678"); - final String actual = aesGcmCryptographer.decrypt(crypted, "helloHowAreyo12345678"); + // GIVEN + final var crypted = aesGcmCryptographer.encrypt(plain, "helloHowAreyo12345678"); + // WHEN + final var actual = aesGcmCryptographer.decrypt(crypted, "helloHowAreyo12345678"); + // THEN assertThat(actual).isEqualTo(plain); } catch (final Exception e) { throw new RuntimeException(e); } }); - } - - // Test to ensure ciphertext is different for the same plaintext due to IV randomness @Test - public void testCiphertextIsDifferentForSamePlaintext() throws Exception { - - // The same plaintext message - String plaintext = "This is a test message to ensure ciphertext is different!"; - - String encryptedMessage1 = aesGcmCryptographer.encrypt(plaintext, "aniceSaltGorYu"); - String encryptedMessage2 = aesGcmCryptographer.encrypt(plaintext, "aniceSaltGorYu"); - + @DisplayName(""" + Given the same plaintext message and encryption key + When encrypting the message twice + Then the resulting ciphertexts should be different due to IV randomness + """) + void testCiphertextIsDifferentForSamePlaintext() throws Exception { + // GIVEN + final var plaintext = "This is a test message to ensure ciphertext is different!"; + // WHEN + final var encryptedMessage1 = aesGcmCryptographer.encrypt(plaintext, "aniceSaltGorYu"); + final var encryptedMessage2 = aesGcmCryptographer.encrypt(plaintext, "aniceSaltGorYu"); + // THEN // Assert that the two ciphertexts are different assertThat(encryptedMessage1).isNotEqualTo(encryptedMessage2); } - } diff --git a/src/test/java/nl/ictu/service/TestAesGcmSivCryptographer.java b/src/test/java/nl/ictu/service/TestAesGcmSivCryptographer.java index 29407d0..81dfc78 100644 --- a/src/test/java/nl/ictu/service/TestAesGcmSivCryptographer.java +++ b/src/test/java/nl/ictu/service/TestAesGcmSivCryptographer.java @@ -1,69 +1,81 @@ package nl.ictu.service; +import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; import lombok.extern.slf4j.Slf4j; -import nl.ictu.Identifier; import nl.ictu.configuration.PseudoniemenServiceProperties; +import nl.ictu.crypto.AesGcmSivCryptographer; +import nl.ictu.crypto.IdentifierConverter; +import nl.ictu.model.Identifier; +import nl.ictu.utils.Base64Wrapper; +import nl.ictu.utils.MessageDigestWrapper; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.test.context.ActiveProfiles; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; - /** - * Class for tesing {@link AesGcmCryptographerImpl} + * Class for testing {@link AesGcmSivCryptographer} */ - @Slf4j @ActiveProfiles("test") -public class TestAesGcmSivCryptographer { - - private final AesGcmSivCryptographer aesGcmSivCryptographer = new AesGcmSivCryptographerImpl( - new PseudoniemenServiceProperties().setIdentifierPrivateKey("QTBtVEhLN3EwMHJ3QXN1ZUFqNzVrT3hDQTBIWWNIZTU="), - new IdentifierConverterImpl(new ObjectMapper()) +class TestAesGcmSivCryptographer { + + private final AesGcmSivCryptographer aesGcmSivCryptographer = new AesGcmSivCryptographer( + new PseudoniemenServiceProperties().setIdentifierPrivateKey( + "QTBtVEhLN3EwMHJ3QXN1ZUFqNzVrT3hDQTBIWWNIZTU="), + new MessageDigestWrapper(), + new IdentifierConverter(new ObjectMapper()), + new Base64Wrapper() ); - - private final Set testStrings = new HashSet<>(Arrays.asList("a", "bb", "dsv", "ghad", "dhaht", "uDg5Av", "d93fdvv", "dj83hzHo", "38iKawKv9", "dk(gkzm)Mh", "gjk)s3$g9cQ")); + private final Set testStrings = new HashSet<>( + Arrays.asList("a", "bb", "dsv", "ghad", "dhaht", "uDg5Av", "d93fdvv", "dj83hzHo", + "38iKawKv9", "dk(gkzm)Mh", "gjk)s3$g9cQ")); @Test - public void testEncyptDecryptForDifferentStringLengths() { + @DisplayName(""" + Given a set of test strings + When encrypting and decrypting each string with a specific key + Then the decrypted identifier's BSN should be equal to the original plain string + """) + void testEncyptDecryptForDifferentStringLengths() { testStrings.forEach(plain -> { - try { - final Identifier identifier = new Identifier(); - identifier.setBsn(plain); - - final String crypted = aesGcmSivCryptographer.encrypt(identifier, "helloHowAreyo12345678"); - final Identifier actual = aesGcmSivCryptographer.decrypt(crypted, "helloHowAreyo12345678"); + // GIVEN + final var crypted = aesGcmSivCryptographer.encrypt(Identifier.builder() + .bsn(plain) + .build(), + "helloHowAreyo12345678"); + // WHEN + final var actual = aesGcmSivCryptographer.decrypt(crypted, + "helloHowAreyo12345678"); + // THEN assertThat(actual.getBsn()).isEqualTo(plain); } catch (final Exception e) { throw new RuntimeException(e); } }); - } - - // Test to ensure ciphertext is different for the same plaintext due to IV randomness @Test - public void testCiphertextIsTheSameForSamePlaintext() throws Exception { - - // The same plaintext message - String plaintext = "This is a test message to ensure ciphertext is different!"; - - final Identifier identifier = new Identifier(); - identifier.setBsn(plaintext); - - String encryptedMessage1 = aesGcmSivCryptographer.encrypt(identifier, "aniceSaltGorYu"); - String encryptedMessage2 = aesGcmSivCryptographer.encrypt(identifier, "aniceSaltGorYu"); - - // Assert that the two ciphertexts are different + @DisplayName(""" + Given the same plaintext message and encryption key + When encrypting the message twice + Then the resulting ciphertexts should be the same due to SIV mode + """) + void testCiphertextIsTheSameForSamePlaintext() throws Exception { + // GIVEN + final var plaintext = "This is a test message to ensure ciphertext is different!"; + final var identifier = Identifier.builder().bsn(plaintext).build(); + // WHEN + final var encryptedMessage1 = aesGcmSivCryptographer.encrypt(identifier, "aniceSaltGorYu"); + final var encryptedMessage2 = aesGcmSivCryptographer.encrypt(identifier, "aniceSaltGorYu"); + // THEN + // Assert that the two ciphertexts are the same assertThat(encryptedMessage1).isEqualTo(encryptedMessage2); } - } diff --git a/src/test/java/nl/ictu/service/map/BsnPseudoMapperTest.java b/src/test/java/nl/ictu/service/map/BsnPseudoMapperTest.java new file mode 100644 index 0000000..de699f9 --- /dev/null +++ b/src/test/java/nl/ictu/service/map/BsnPseudoMapperTest.java @@ -0,0 +1,85 @@ +package nl.ictu.service.map; + +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.ORGANISATION_PSEUDO; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import nl.ictu.crypto.AesGcmSivCryptographer; +import nl.ictu.model.Identifier; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierResponse; +import org.bouncycastle.crypto.InvalidCipherTextException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BsnPseudoMapperTest { + + @Mock + private AesGcmSivCryptographer aesGcmSivCryptographer; + @InjectMocks + private BsnPseudoMapper bsnPseudoMapper; + + @Test + @DisplayName(""" + Given a valid BSN and OIN + When encryption succeeds + Then a valid WsExchangeIdentifierResponse is returned + """) + void map_WhenEncryptionSucceeds_ShouldReturnWsExchangeIdentifierResponse() throws Exception { + // GIVEN + final var bsn = "123456789"; + final var oin = "OIN_X"; + final var encryptedValue = "encryptedBsn123"; + when(aesGcmSivCryptographer.encrypt(any(Identifier.class), eq(oin))) + .thenReturn(encryptedValue); + // WHEN + final var response = bsnPseudoMapper.map(bsn, oin); + // THEN + assertNotNull(response); + assertNotNull(response.getIdentifier()); + assertEquals(ORGANISATION_PSEUDO, response.getIdentifier().getType()); + assertEquals(encryptedValue, response.getIdentifier().getValue()); + } + + @Test + @DisplayName(""" + Given a BSN and OIN + When encryption throws IOException + Then an IOException is thrown + """) + void map_WhenEncryptThrowsIOException_ShouldThrowIOException() throws Exception { + // GIVEN + final var bsn = "987654321"; + final var oin = "OIN_IO"; + when(aesGcmSivCryptographer.encrypt(any(Identifier.class), eq(oin))) + .thenThrow(new IOException("Simulated I/O error")); + // WHEN & THEN + assertThrows(IOException.class, () -> bsnPseudoMapper.map(bsn, oin)); + } + + @Test + @DisplayName(""" + Given a BSN and OIN + When encryption throws InvalidCipherTextException + Then an InvalidCipherTextException is thrown + """) + void map_WhenEncryptThrowsInvalidCipherTextException_ShouldThrowInvalidCipherTextException() + throws Exception { + // GIVEN + final var bsn = "111222333"; + final var oin = "OIN_CIPHER"; + when(aesGcmSivCryptographer.encrypt(any(Identifier.class), eq(oin))) + .thenThrow(new InvalidCipherTextException("Simulated cipher error")); + // WHEN & THEN + assertThrows(InvalidCipherTextException.class, () -> bsnPseudoMapper.map(bsn, oin)); + } +} diff --git a/src/test/java/nl/ictu/service/map/BsnTokenMapperTest.java b/src/test/java/nl/ictu/service/map/BsnTokenMapperTest.java new file mode 100644 index 0000000..9dc28f1 --- /dev/null +++ b/src/test/java/nl/ictu/service/map/BsnTokenMapperTest.java @@ -0,0 +1,64 @@ +package nl.ictu.service.map; + +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.BSN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import nl.ictu.model.Token; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class BsnTokenMapperTest { + + private BsnTokenMapper bsnTokenMapper; + + @BeforeEach + void setUp() { + + bsnTokenMapper = new BsnTokenMapper(); + } + + @Test + @DisplayName(""" + Given a token with a valid BSN + When mapped + Then the response contains an identifier with the correct BSN type and value + """) + void map_WhenTokenHasValidBsn_ShouldReturnResponseWithBsnIdentifier() { + // GIVEN + final var token = Token.builder() + .bsn("123456789") + .build(); + // WHEN + final var response = bsnTokenMapper.map(token); + // THEN + assertNotNull(response, "Response should not be null"); + assertNotNull(response.getIdentifier(), "Identifier should not be null"); + assertEquals(BSN, response.getIdentifier().getType(), "Identifier type should be BSN"); + assertEquals("123456789", response.getIdentifier().getValue(), + "Identifier value should match token’s BSN"); + } + + @Test + @DisplayName(""" + Given a token without a BSN + When mapped + Then the response contains an identifier with BSN type but a null value + """) + void map_WhenTokenHasNoBsn_ShouldHandleNullBsnGracefully() { + // GIVEN + final var token = Token.builder().build(); // No BSN set + // WHEN + final var response = bsnTokenMapper.map(token); + // THEN + assertNotNull(response, "Response should not be null even if BSN is null"); + assertNotNull(response.getIdentifier(), "Identifier should not be null"); + assertEquals(BSN, response.getIdentifier().getType(), + "Identifier type should still be BSN"); + assertNull(response.getIdentifier().getValue(), + "Value should be null if token’s BSN is null"); + } +} diff --git a/src/test/java/nl/ictu/service/map/EncryptedBsnMapperTest.java b/src/test/java/nl/ictu/service/map/EncryptedBsnMapperTest.java new file mode 100644 index 0000000..24bf33e --- /dev/null +++ b/src/test/java/nl/ictu/service/map/EncryptedBsnMapperTest.java @@ -0,0 +1,45 @@ +package nl.ictu.service.map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import nl.ictu.crypto.AesGcmSivCryptographer; +import nl.ictu.model.Identifier; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class EncryptedBsnMapperTest { + + @Mock + private AesGcmSivCryptographer aesGcmSivCryptographer; + @InjectMocks + private EncryptedBsnMapper encryptedBsnMapper; + + @Test + @DisplayName(""" + Given an encrypted BSN and a recipient OIN + When decryption succeeds + Then the decrypted BSN is returned + """) + void map_WhenDecryptSucceeds_ShouldReturnDecryptedBsn() { + // GIVEN + final var encryptedBsn = "someEncryptedValue"; + final var recipientOin = "testOIN"; + final var expectedBsn = "123456789"; + final var decryptedIdentifier = Identifier.builder() + .bsn(expectedBsn) + .build(); + when(aesGcmSivCryptographer.decrypt(encryptedBsn, recipientOin)) + .thenReturn(decryptedIdentifier); + // WHEN + String result = encryptedBsnMapper.map(encryptedBsn, recipientOin); + // THEN + assertEquals(expectedBsn, result, + "The decrypted BSN should match the expected value"); + } +} diff --git a/src/test/java/nl/ictu/service/map/OrganisationPseudoTokenMapperTest.java b/src/test/java/nl/ictu/service/map/OrganisationPseudoTokenMapperTest.java new file mode 100644 index 0000000..cb15274 --- /dev/null +++ b/src/test/java/nl/ictu/service/map/OrganisationPseudoTokenMapperTest.java @@ -0,0 +1,91 @@ +package nl.ictu.service.map; + +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.ORGANISATION_PSEUDO; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import nl.ictu.crypto.AesGcmSivCryptographer; +import nl.ictu.model.Identifier; +import nl.ictu.model.Token; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeTokenResponse; +import org.bouncycastle.crypto.InvalidCipherTextException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link OrganisationPseudoTokenMapper}. + */ +@ExtendWith(MockitoExtension.class) +class OrganisationPseudoTokenMapperTest { + + @Mock + private AesGcmSivCryptographer aesGcmSivCryptographer; + @InjectMocks + private OrganisationPseudoTokenMapper organisationPseudoTokenMapper; + + @Test + @DisplayName(""" + Given a valid token and caller OIN + When encryption succeeds + Then the response should contain the encrypted identifier + """) + void map_WhenEncryptionSucceeds_ShouldReturnEncryptedTokenResponse() throws Exception { + // GIVEN + final var callerOIN = "TEST_OIN"; + final var token = Token.builder().bsn("123456789").build(); + final var encryptedValue = "encryptedBSN"; + when(aesGcmSivCryptographer.encrypt(any(Identifier.class), eq(callerOIN))) + .thenReturn(encryptedValue); + // WHEN + final var response = organisationPseudoTokenMapper.map(callerOIN, token); + // THEN + assertEquals(ORGANISATION_PSEUDO, response.getIdentifier().getType(), + "The identifier type should be ORGANISATION_PSEUDO"); + assertEquals(encryptedValue, response.getIdentifier().getValue(), + "The identifier value should match the encrypted BSN"); + } + + @Test + @DisplayName(""" + Given a valid token and caller OIN + When encryption fails with InvalidCipherTextException + Then an InvalidCipherTextException should be thrown + """) + void map_WhenEncryptionFails_ShouldThrowInvalidCipherTextException() throws Exception { + // GIVEN + final var callerOIN = "FAILING_OIN"; + final var token = Token.builder().bsn("987654321").build(); + when(aesGcmSivCryptographer.encrypt(any(Identifier.class), eq(callerOIN))) + .thenThrow(new InvalidCipherTextException("Simulated cipher error")); + // WHEN & THEN + assertThrows(InvalidCipherTextException.class, + () -> organisationPseudoTokenMapper.map(callerOIN, token), + "Expected InvalidCipherTextException to be thrown"); + } + + @Test + @DisplayName(""" + Given a valid token and caller OIN + When encryption fails with IOException + Then an IOException should be thrown + """) + void map_WhenEncryptionThrowsIOException_ShouldThrowIOException() throws Exception { + // GIVEN + final var callerOIN = "IO_EXCEPTION_OIN"; + final var token = Token.builder().bsn("555555555").build(); + when(aesGcmSivCryptographer.encrypt(any(Identifier.class), eq(callerOIN))) + .thenThrow(new IOException("Simulated I/O error")); + // WHEN & THEN + assertThrows(IOException.class, + () -> organisationPseudoTokenMapper.map(callerOIN, token), + "Expected IOException to be thrown"); + } +} diff --git a/src/test/java/nl/ictu/service/map/PseudoBsnMapperTest.java b/src/test/java/nl/ictu/service/map/PseudoBsnMapperTest.java new file mode 100644 index 0000000..ccccc83 --- /dev/null +++ b/src/test/java/nl/ictu/service/map/PseudoBsnMapperTest.java @@ -0,0 +1,54 @@ +package nl.ictu.service.map; + +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.BSN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +import nl.ictu.crypto.AesGcmSivCryptographer; +import nl.ictu.model.Identifier; +import nl.ictu.pseudoniemenservice.generated.server.model.WsExchangeIdentifierResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link PseudoBsnMapper}. + */ +@ExtendWith(MockitoExtension.class) +class PseudoBsnMapperTest { + + @Mock + private AesGcmSivCryptographer aesGcmSivCryptographer; + @InjectMocks + private PseudoBsnMapper pseudoBsnMapper; + + @Test + @DisplayName(""" + Given a valid pseudo and OIN + When decryption succeeds + Then the response should contain the decrypted BSN + """) + void map_WhenDecryptionSucceeds_ShouldReturnDecryptedBsn() throws Exception { + // GIVEN + var pseudo = "someEncryptedString"; + var oin = "TEST_OIN"; + // Suppose the decrypted Identifier has BSN "123456789" + final var decryptedIdentifier = Identifier.builder() + .bsn("123456789") + .build(); + when(aesGcmSivCryptographer.decrypt(pseudo, oin)) + .thenReturn(decryptedIdentifier); + // WHEN + WsExchangeIdentifierResponse response = pseudoBsnMapper.map(pseudo, oin); + // THEN + assertNotNull(response, "Response should not be null"); + assertNotNull(response.getIdentifier(), "Identifier should not be null"); + assertEquals(BSN, response.getIdentifier().getType(), "Type should be BSN"); + assertEquals("123456789", response.getIdentifier().getValue(), + "Decrypted BSN value should match"); + } +} diff --git a/src/test/java/nl/ictu/service/map/WsGetTokenResponseMapperTest.java b/src/test/java/nl/ictu/service/map/WsGetTokenResponseMapperTest.java new file mode 100644 index 0000000..496b41f --- /dev/null +++ b/src/test/java/nl/ictu/service/map/WsGetTokenResponseMapperTest.java @@ -0,0 +1,132 @@ +package nl.ictu.service.map; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.security.InvalidKeyException; +import nl.ictu.crypto.AesGcmCryptographer; +import nl.ictu.crypto.TokenCoder; +import nl.ictu.model.Token; +import nl.ictu.pseudoniemenservice.generated.server.model.WsGetTokenResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class WsGetTokenResponseMapperTest { + + private final String bsn = "987654321"; + private final long creationDate = System.currentTimeMillis(); + private final String recipientOIN = "123456789"; + private final String encodedToken = "encoded-token"; + + @Mock + private AesGcmCryptographer aesGcmCryptographer; + + @Mock + private TokenCoder tokenCoder; + + @InjectMocks + private WsGetTokenResponseMapper wsGetTokenResponseMapper; + + @Test + @DisplayName(""" + Given a valid bsn, creation date, and recipient OIN + When token encoding and encryption succeed + Then the response should contain the encrypted token + """) + void testMap_Success() throws Exception { + final var encryptedToken = "encrypted-token"; + // GIVEN + final var token = Token.builder() + .version(WsGetTokenResponseMapper.V_1) + .bsn(bsn) + .creationDate(creationDate) + .recipientOIN(recipientOIN) + .build(); + when(tokenCoder.encode(token)).thenReturn(encodedToken); + when(aesGcmCryptographer.encrypt(encodedToken, recipientOIN)).thenReturn(encryptedToken); + // WHEN + final var response = wsGetTokenResponseMapper.map(bsn, creationDate, recipientOIN); + // THEN + verify(tokenCoder).encode(token); + verify(aesGcmCryptographer).encrypt(encodedToken, recipientOIN); + org.junit.jupiter.api.Assertions.assertEquals(encryptedToken, response.getToken()); + } + + @Test + @DisplayName(""" + Given a valid bsn, creation date, and recipient OIN + When token encoding fails with IOException + Then an IOException should be thrown + """) + void testMap_EncodingIOException() throws Exception { + // GIVEN + final var token = Token.builder() + .version(WsGetTokenResponseMapper.V_1) + .bsn(bsn) + .creationDate(creationDate) + .recipientOIN(recipientOIN) + .build(); + when(tokenCoder.encode(token)).thenThrow(new IOException("Encoding failed")); + // WHEN & THEN + assertThrows(IOException.class, + () -> wsGetTokenResponseMapper.map(bsn, creationDate, recipientOIN)); + verify(tokenCoder).encode(token); + verifyNoInteractions(aesGcmCryptographer); + } + + @Test + @DisplayName(""" + Given a valid bsn, creation date, and recipient OIN + When encryption fails with InvalidKeyException + Then an InvalidKeyException should be thrown + """) + void testMap_EncryptionError() throws Exception { + // GIVEN + final var token = Token.builder() + .version(WsGetTokenResponseMapper.V_1) + .bsn(bsn) + .creationDate(creationDate) + .recipientOIN(recipientOIN) + .build(); + when(tokenCoder.encode(token)).thenReturn(encodedToken); + when(aesGcmCryptographer.encrypt(encodedToken, recipientOIN)) + .thenThrow(new InvalidKeyException("Invalid encryption key")); + // WHEN & THEN + assertThrows(InvalidKeyException.class, + () -> wsGetTokenResponseMapper.map(bsn, creationDate, recipientOIN)); + verify(tokenCoder).encode(token); + verify(aesGcmCryptographer).encrypt(encodedToken, recipientOIN); + } + + @Test + @DisplayName(""" + Given a valid bsn, creation date, and recipient OIN + When encryption fails with a runtime exception + Then a RuntimeException should be thrown + """) + void testMap_UnexpectedError() throws Exception { + // GIVEN + final var token = Token.builder() + .version(WsGetTokenResponseMapper.V_1) + .bsn(bsn) + .creationDate(creationDate) + .recipientOIN(recipientOIN) + .build(); + when(tokenCoder.encode(token)).thenReturn(encodedToken); + when(aesGcmCryptographer.encrypt(encodedToken, recipientOIN)).thenThrow( + new RuntimeException("Unexpected error")); + // WHEN & THEN + assertThrows(RuntimeException.class, + () -> wsGetTokenResponseMapper.map(bsn, creationDate, recipientOIN)); + verify(tokenCoder).encode(token); + verify(aesGcmCryptographer).encrypt(encodedToken, recipientOIN); + } +} diff --git a/src/test/java/nl/ictu/service/map/WsIdentifierOinBsnMapperTest.java b/src/test/java/nl/ictu/service/map/WsIdentifierOinBsnMapperTest.java new file mode 100644 index 0000000..a54c112 --- /dev/null +++ b/src/test/java/nl/ictu/service/map/WsIdentifierOinBsnMapperTest.java @@ -0,0 +1,65 @@ +package nl.ictu.service.map; + +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.BSN; +import static nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifierTypes.ORGANISATION_PSEUDO; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import nl.ictu.pseudoniemenservice.generated.server.model.WsIdentifier; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class WsIdentifierOinBsnMapperTest { + + @Mock + private EncryptedBsnMapper encryptedBsnMapper; + + @InjectMocks + private WsIdentifierOinBsnMapper wsIdentifierOinBsnMapper; + + @Test + @DisplayName(""" + Given a WsIdentifier of type BSN with a BSN value + When the map() method is called + Then the BSN value should be returned directly + """) + void testMap_BsnType() { + // GIVEN + final var bsnValue = "987654321"; + final var identifier = new WsIdentifier().type(BSN).value(bsnValue); + // WHEN + final var result = wsIdentifierOinBsnMapper.map(identifier, "123456789"); + // THEN + assertEquals(bsnValue, result); + verifyNoInteractions(encryptedBsnMapper); // Ensure EncryptedBsnMapper is not called + } + + @Test + @DisplayName(""" + Given a WsIdentifier of type ORGANISATION_PSEUDO with a BSN value + When the map() method is called + Then the encrypted value should be returned + """) + void testMap_OrganisationPseudoType() { + // GIVEN + final var bsnValue = "987654321"; + final var recipientOIN = "123456789"; + final var encryptedValue = "encrypted-value"; + final var identifier = new WsIdentifier() + .type(ORGANISATION_PSEUDO) + .value(bsnValue); + when(encryptedBsnMapper.map(bsnValue, recipientOIN)).thenReturn(encryptedValue); + // WHEN + String result = wsIdentifierOinBsnMapper.map(identifier, recipientOIN); + // THEN + assertEquals(encryptedValue, result); + verify(encryptedBsnMapper).map(bsnValue, recipientOIN); // Ensure EncryptedBsnMapper is called + } +} diff --git a/src/test/java/nl/ictu/service/validate/OINValidatorTest.java b/src/test/java/nl/ictu/service/validate/OINValidatorTest.java new file mode 100644 index 0000000..4ad487c --- /dev/null +++ b/src/test/java/nl/ictu/service/validate/OINValidatorTest.java @@ -0,0 +1,72 @@ +package nl.ictu.service.validate; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import nl.ictu.model.Token; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class OINValidatorTest { + + private OINValidator oinValidator; + + @BeforeEach + void setUp() { + + oinValidator = new OINValidator(); + } + + @Test + @DisplayName(""" + Given a caller OIN and a token with matching recipientOIN + When isValid() is called + Then it should return true + """) + void isValid_ReturnsTrue_WhenOINsMatch() { + // GIVEN + final var callerOIN = "TEST_OIN_123"; + final var token = Token.builder() + .recipientOIN("TEST_OIN_123") + .build(); + // WHEN + final var result = oinValidator.isValid(callerOIN, token); + // THEN + assertTrue(result, "Expected isValid() to return true for matching OINs"); + } + + @Test + @DisplayName(""" + Given a caller OIN and a token with non-matching recipientOIN + When isValid() is called + Then it should return false + """) + void isValid_ReturnsFalse_WhenOINsDoNotMatch() { + // GIVEN + final var callerOIN = "TEST_OIN_ABC"; + final var token = Token.builder() + .recipientOIN("TEST_OIN_XYZ") + .build(); + // WHEN + final var result = oinValidator.isValid(callerOIN, token); + // THEN + assertFalse(result, "Expected isValid() to return false for non-matching OINs"); + } + + @Test + @DisplayName(""" + Given a caller OIN and a token with a null recipientOIN + When isValid() is called + Then it should return false + """) + void isValid_Behavior_WhenTokenRecipientOINIsNull() { + // GIVEN + final var callerOIN = "NON_NULL_OIN"; + final var token = Token.builder().build(); + // WHEN + final var result = oinValidator.isValid(callerOIN, token); + // THEN + assertFalse(result, "Expected isValid() to return false if token's recipientOIN is null"); + } +} diff --git a/src/test/java/nl/ictu/utils/AESHelperTest.java b/src/test/java/nl/ictu/utils/AESHelperTest.java new file mode 100644 index 0000000..b0fdd0b --- /dev/null +++ b/src/test/java/nl/ictu/utils/AESHelperTest.java @@ -0,0 +1,93 @@ +package nl.ictu.utils; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.security.NoSuchAlgorithmException; +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.GCMParameterSpec; +import org.bouncycastle.crypto.MultiBlockCipher; +import org.bouncycastle.crypto.engines.AESEngine; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class AESHelperTest { + + @Test + @DisplayName(""" + Given no input + When generating an IV using AesUtility.generateIV() + Then a non-null GCMParameterSpec should be returned with the correct IV length and tag length + """) + void generateIV_ShouldReturnGCMParameterSpec_WithNonNullIV() { + // WHEN + final var gcmParameterSpec = AesUtility.generateIV(); + // THEN + assertNotNull(gcmParameterSpec, "GCMParameterSpec should not be null"); + assertEquals(AesUtility.TAG_LENGTH, gcmParameterSpec.getTLen(), + "Tag length should be 128 (bits)"); + // The IV array is extracted from gcmParameterSpec + final byte[] iv = gcmParameterSpec.getIV(); + assertNotNull(iv, "IV should not be null"); + assertEquals(AesUtility.IV_LENGTH, iv.length, + "IV length should be " + AesUtility.IV_LENGTH); + } + + @Test + @DisplayName(""" + Given a byte array of IV values + When creating a GCMParameterSpec using AesUtility.createIVfromValues() + Then the resulting GCMParameterSpec should match the input IV values + """) + void createIVfromValues_ShouldReturnGCMParameterSpec_FromGivenIV() { + // GIVEN + final byte[] ivSource = new byte[AesUtility.IV_LENGTH]; + // Fill the array with deterministic data for test + for (int i = 0; i < ivSource.length; i++) { + ivSource[i] = (byte) i; + } + // WHEN + final var spec = AesUtility.createIVfromValues(ivSource); + // THEN + assertNotNull(spec, "GCMParameterSpec should not be null"); + assertEquals(AesUtility.TAG_LENGTH, spec.getTLen(), + "Tag length should be 128 (bits)"); + assertArrayEquals(ivSource, spec.getIV(), + "IV array in GCMParameterSpec should match the input"); + } + + @Test + @DisplayName(""" + Given no input + When creating a Cipher instance using AesUtility.createCipher() + Then the resulting Cipher should be of type AES/GCM/NoPadding + """) + void createCipher_ShouldReturnAesGcmNoPaddingCipher() + throws NoSuchPaddingException, NoSuchAlgorithmException { + // WHEN + final var cipher = AesUtility.createCipher(); + // THEN + assertNotNull(cipher, "Cipher should not be null"); + // Depending on the JVM/provider, the algorithm name can be uppercase or some variation, + // but typically you'd expect "AES/GCM/NoPadding". + assertEquals("AES/GCM/NoPadding", cipher.getAlgorithm(), + "Cipher algorithm should match AES/GCM/NoPadding"); + } + + @Test + @DisplayName(""" + Given no input + When retrieving the AES engine using AesUtility.getAESEngine() + Then the resulting engine should be an instance of AESEngine + """) + void getAESEngine_ShouldReturnNonNullAESEngineInstance() { + // WHEN + final var engine = AesUtility.getAESEngine(); + // THEN + assertNotNull(engine, "Engine should not be null"); + assertInstanceOf(AESEngine.class, engine, "Engine should be an instance of AESEngine"); + } +} diff --git a/src/test/java/nl/ictu/utils/AesUtilityTest.java b/src/test/java/nl/ictu/utils/AesUtilityTest.java new file mode 100644 index 0000000..a33dbbc --- /dev/null +++ b/src/test/java/nl/ictu/utils/AesUtilityTest.java @@ -0,0 +1,88 @@ +package nl.ictu.utils; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.security.NoSuchAlgorithmException; +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.GCMParameterSpec; +import org.bouncycastle.crypto.MultiBlockCipher; +import org.bouncycastle.crypto.engines.AESEngine; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class AesUtilityTest { + + @Test + @DisplayName(""" + Given no input + When generating an IV using AesUtility.generateIV() + Then a non-null GCMParameterSpec should be returned with the correct IV length and tag length + """) + void generateIV_ShouldReturnGCMParameterSpec() { + // WHEN + final var spec = AesUtility.generateIV(); + // THEN + assertNotNull(spec, "GCMParameterSpec should not be null"); + assertEquals(AesUtility.TAG_LENGTH, spec.getTLen(), + "Tag length should be " + AesUtility.TAG_LENGTH + " bits"); + assertNotNull(spec.getIV(), "IV should not be null"); + assertEquals(AesUtility.IV_LENGTH, spec.getIV().length, + "IV length should be " + AesUtility.IV_LENGTH); + } + + @Test + @DisplayName(""" + Given a byte array of IV values + When creating a GCMParameterSpec using AesUtility.createIVfromValues() + Then the resulting GCMParameterSpec should match the input IV values + """) + void createIVfromValues_ShouldReturnGCMParameterSpecFromGivenIV() { + // GIVEN: a deterministic IV of length 12 + final var ivBytes = new byte[AesUtility.IV_LENGTH]; + for (int i = 0; i < ivBytes.length; i++) { + ivBytes[i] = (byte) i; + } + // WHEN + final var spec = AesUtility.createIVfromValues(ivBytes); + // THEN + assertNotNull(spec, "GCMParameterSpec should not be null"); + assertEquals(AesUtility.TAG_LENGTH, spec.getTLen(), + "Tag length should be " + AesUtility.TAG_LENGTH + " bits"); + assertArrayEquals(ivBytes, spec.getIV(), + "IV in GCMParameterSpec should match the input array"); + } + + @Test + @DisplayName(""" + Given no input + When creating a Cipher instance using AesUtility.createCipher() + Then the resulting Cipher should be of type AES/GCM/NoPadding + """) + void createCipher_ShouldReturnAesGcmNoPadding() + throws NoSuchPaddingException, NoSuchAlgorithmException { + // WHEN + final var cipher = AesUtility.createCipher(); + // THEN + assertNotNull(cipher, "Cipher should not be null"); + assertEquals("AES/GCM/NoPadding", cipher.getAlgorithm(), + "Expected the cipher algorithm to be AES/GCM/NoPadding"); + } + + @Test + @DisplayName(""" + Given no input + When retrieving the AES engine using AesUtility.getAESEngine() + Then the resulting engine should be an instance of AESEngine + """) + void getAESEngine_ShouldReturnAesEngine() { + // WHEN + final var engine = AesUtility.getAESEngine(); + // THEN + assertNotNull(engine, "Engine should not be null"); + assertInstanceOf(AESEngine.class, engine, "Engine should be an instance of AESEngine"); + } +} diff --git a/src/test/java/nl/ictu/utils/Base64WrapperTest.java b/src/test/java/nl/ictu/utils/Base64WrapperTest.java new file mode 100644 index 0000000..995bccb --- /dev/null +++ b/src/test/java/nl/ictu/utils/Base64WrapperTest.java @@ -0,0 +1,84 @@ +package nl.ictu.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class Base64WrapperTest { + + private Base64Wrapper base64Wrapper; + + @BeforeEach + void setUp() { + base64Wrapper = new Base64Wrapper(); + } + + @Test + @DisplayName(""" + Given a byte array of the string "Hello" + When encoding the byte array using Base64Wrapper.encode() + Then the result should be the Base64-encoded string "SGVsbG8=" + """) + void encode_ShouldEncodeBytesToBase64Bytes() { + // GIVEN + final var input = "Hello".getBytes(StandardCharsets.UTF_8); + // WHEN + final var result = base64Wrapper.encode(input); + // THEN + final var resultAsString = new String(result, StandardCharsets.UTF_8); + assertEquals("SGVsbG8=", resultAsString, + "Expected Base64 encoding of 'Hello' to be 'SGVsbG8='"); + } + + @Test + @DisplayName(""" + Given a byte array of the string "Hello" + When encoding the byte array using Base64Wrapper.encodeToString() + Then the result should be the Base64-encoded string "SGVsbG8=" + """) + void encodeToString_ShouldEncodeBytesToBase64String() { + // GIVEN + final var input = "Hello".getBytes(StandardCharsets.UTF_8); + // WHEN + final var base64String = base64Wrapper.encodeToString(input); + // THEN + assertEquals("SGVsbG8=", base64String, + "Expected Base64 encoding of 'Hello' to be 'SGVsbG8='"); + } + + @Test + @DisplayName(""" + Given a Base64-encoded string "SGVsbG8=" + When decoding the string using Base64Wrapper.decode() + Then the result should be the decoded byte array representing "Hello" + """) + void decode_ShouldDecodeBase64StringToBytes() { + // GIVEN + final var base64String = "SGVsbG8="; + // WHEN + final var decoded = base64Wrapper.decode(base64String); + // THEN + final var decodedAsString = new String(decoded, StandardCharsets.UTF_8); + assertEquals("Hello", decodedAsString, + "Expected Base64 decoding of 'SGVsbG8=' to be 'Hello'"); + } + + @Test + @DisplayName(""" + Given an invalid Base64 string "Not valid base64!!!" + When attempting to decode using Base64Wrapper.decode() + Then an IllegalArgumentException should be thrown + """) + void decode_ShouldThrowException_WhenInvalidBase64String() { + // GIVEN + final var invalidBase64 = "Not valid base64!!!"; + // WHEN & THEN + assertThrows(IllegalArgumentException.class, + () -> base64Wrapper.decode(invalidBase64), + "Expected decode() to throw IllegalArgumentException for invalid Base64 string"); + } +} diff --git a/src/test/java/nl/ictu/utils/ByteArrayUtilsTest.java b/src/test/java/nl/ictu/utils/ByteArrayUtilsTest.java new file mode 100644 index 0000000..ddec53f --- /dev/null +++ b/src/test/java/nl/ictu/utils/ByteArrayUtilsTest.java @@ -0,0 +1,68 @@ +package nl.ictu.utils; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ByteArrayUtilsTest { + + @Test + @DisplayName(""" + Given two non-empty byte arrays [1, 2, 3] and [4, 5, 6] + When concatenating the arrays using ByteArrayUtil.concat() + Then the result should be a single byte array [1, 2, 3, 4, 5, 6] + """) + void concat_ShouldConcatenateTwoArrays() { + // GIVEN + byte[] a = {1, 2, 3}; + byte[] b = {4, 5, 6}; + byte[] expected = {1, 2, 3, 4, 5, 6}; + // WHEN + byte[] result = ByteArrayUtil.concat(a, b); + // THEN + assertArrayEquals(expected, result); + } + + @Test + @DisplayName(""" + Given two empty byte arrays + When concatenating the arrays using ByteArrayUtil.concat() + Then the result should be an empty byte array + """) + void concat_ShouldHandleTwoEmptyArrays() { + // GIVEN + byte[] a = {}; + byte[] b = {}; + byte[] expected = {}; + // WHEN + byte[] result = ByteArrayUtil.concat(a, b); + // THEN + assertArrayEquals(expected, result); + } + + @Test + @DisplayName(""" + Given one empty byte array and one non-empty byte array + When concatenating the arrays using ByteArrayUtil.concat() + Then the result should be the non-empty array + """) + void concat_ShouldHandleOneEmptyArray() { + // GIVEN + byte[] a = {1, 2, 3}; + byte[] b = {}; + byte[] expected1 = {1, 2, 3}; + // WHEN + byte[] result1 = ByteArrayUtil.concat(a, b); + // THEN + assertArrayEquals(expected1, result1); + // GIVEN + byte[] c = {}; + byte[] d = {4, 5, 6}; + byte[] expected2 = {4, 5, 6}; + // WHEN + byte[] result2 = ByteArrayUtil.concat(c, d); + // THEN + assertArrayEquals(expected2, result2); + } +} diff --git a/src/test/java/nl/ictu/utils/MessageDigestWrapperTest.java b/src/test/java/nl/ictu/utils/MessageDigestWrapperTest.java new file mode 100644 index 0000000..1e80a61 --- /dev/null +++ b/src/test/java/nl/ictu/utils/MessageDigestWrapperTest.java @@ -0,0 +1,34 @@ +package nl.ictu.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.security.MessageDigest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MessageDigestWrapperTest { + + private MessageDigestWrapper messageDigestWrapper; + + @BeforeEach + void setUp() { + messageDigestWrapper = new MessageDigestWrapper(); + } + + @Test + @DisplayName(""" + Given a MessageDigestWrapper instance + When calling getMessageDigestInstance() + Then the resulting MessageDigest should be SHA-256 + """) + void getMessageDigestSha256_ShouldReturnSha256Digest() { + // WHEN + final var digest = messageDigestWrapper.getMessageDigestInstance(); + // THEN + assertNotNull(digest, "MessageDigest should not be null"); + assertEquals("SHA-256", digest.getAlgorithm(), + "Expected the digest algorithm to be SHA-256"); + } +}