diff --git a/token-checker-server/src/main/java/de/ozgcloud/token/TokenValidationResult.java b/token-checker-server/src/main/java/de/ozgcloud/token/TokenValidationResult.java index cf1cc499ceded34e6f63f4690d30d455dab0fcaf..36cc06edf5deccc04e12ebe44d20cfecb1d8695f 100644 --- a/token-checker-server/src/main/java/de/ozgcloud/token/TokenValidationResult.java +++ b/token-checker-server/src/main/java/de/ozgcloud/token/TokenValidationResult.java @@ -22,6 +22,7 @@ package de.ozgcloud.token; import java.util.List; +import de.ozgcloud.token.common.errorhandling.ValidationError; import lombok.Builder; import lombok.Getter; import lombok.Singular; @@ -31,10 +32,8 @@ import lombok.Singular; public class TokenValidationResult { private final boolean valid; - private final String postfachId; - private final String trustLevel; + private final TokenAttributes attributes; @Singular - private final List<TokenAttribute> attributes; - private final String errorMesssage; + private final List<ValidationError> validationErrors; } diff --git a/token-checker-server/src/main/java/de/ozgcloud/token/saml/SamlTokenService.java b/token-checker-server/src/main/java/de/ozgcloud/token/saml/SamlTokenService.java index f6612ed6f27c5d1b1a79b8f0e5e3e145b6981f6e..880ac20978843b46e132d0f8cae62f70894563b0 100644 --- a/token-checker-server/src/main/java/de/ozgcloud/token/saml/SamlTokenService.java +++ b/token-checker-server/src/main/java/de/ozgcloud/token/saml/SamlTokenService.java @@ -26,10 +26,9 @@ package de.ozgcloud.token.saml; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; -import java.util.Collection; import java.util.Optional; -import java.util.Set; import org.opensaml.core.xml.io.UnmarshallingException; import org.opensaml.saml.saml2.core.Issuer; @@ -38,7 +37,7 @@ import org.opensaml.saml.saml2.core.impl.ResponseUnmarshaller; import org.springframework.stereotype.Service; import de.ozgcloud.common.errorhandling.TechnicalException; -import de.ozgcloud.token.TokenAttribute; +import de.ozgcloud.token.TokenAttributes; import de.ozgcloud.token.TokenValidationResult; import de.ozgcloud.token.common.errorhandling.TokenVerificationException; import lombok.RequiredArgsConstructor; @@ -57,15 +56,16 @@ public class SamlTokenService { public TokenValidationResult validate(String token) { try { - return buildValidTokenResult(validate(parseToken(token))); + return buildValidTokenResult(getAttributes(parseToken(token))); } catch (TokenVerificationException e) { LOG.debug("Token validation failed", e); + e.getValidationErrors().forEach(validationError -> LOG.error(validationError.getMessage(), validationError.getCause())); return buildInvalidTokenResult(e); } } Response parseToken(String token) { - try (var inputStream = new ByteArrayInputStream(token.getBytes(StandardCharsets.UTF_8));) { + try (var inputStream = buildInputStream(token)) { var element = parserPool.parse(inputStream).getDocumentElement(); return (Response) responseUnmarshaller.unmarshall(element); } catch (IOException | XMLParserException | UnmarshallingException e) { @@ -73,14 +73,12 @@ public class SamlTokenService { } } - Set<TokenAttribute> validate(Response token) { - var tokenIssuer = getTokenIssuer(token); - return getValidationService(tokenIssuer).validate(token); + InputStream buildInputStream(String token) { + return new ByteArrayInputStream(token.getBytes(StandardCharsets.UTF_8)); } - SamlAttributeService getValidationService(String tokenIssuer) { - return samlServiceRegistry.getService(tokenIssuer) - .orElseThrow(() -> new TechnicalException("No validation service found for issuer %s".formatted(tokenIssuer))); + TokenAttributes getAttributes(Response token) { + return getSamlAttributeService(getTokenIssuer(token)).getAttributes(token); } String getTokenIssuer(Response token) { @@ -88,24 +86,19 @@ public class SamlTokenService { .orElseThrow(() -> new TokenVerificationException("No token issuer found")); } - TokenValidationResult buildValidTokenResult(Collection<TokenAttribute> tokenAttributes) { - var resultBuilder = TokenValidationResult.builder().valid(true); - for (var attr : tokenAttributes) { - if (attr.isPostfachId()) { - resultBuilder.postfachId(attr.getValue()); - } else if (attr.isTrustLevel()) { - resultBuilder.trustLevel(attr.getValue()); - } else { - resultBuilder.attribute(attr); - } - } - return resultBuilder.build(); + SamlAttributeService getSamlAttributeService(String tokenIssuer) { + return samlServiceRegistry.getService(tokenIssuer) + .orElseThrow(() -> new TechnicalException("Can't validate token with issuer %s".formatted(tokenIssuer))); + } + + TokenValidationResult buildValidTokenResult(TokenAttributes tokenAttributes) { + return TokenValidationResult.builder().valid(true).attributes(tokenAttributes).build(); } TokenValidationResult buildInvalidTokenResult(TokenVerificationException exception) { return TokenValidationResult.builder() .valid(false) - .errorMesssage(exception.getMessage()) + .validationErrors(exception.getValidationErrors()) .build(); } diff --git a/token-checker-server/src/test/java/de/ozgcloud/token/CheckTokenResultTestFactory.java b/token-checker-server/src/test/java/de/ozgcloud/token/TokenValidationResultTestFactory.java similarity index 56% rename from token-checker-server/src/test/java/de/ozgcloud/token/CheckTokenResultTestFactory.java rename to token-checker-server/src/test/java/de/ozgcloud/token/TokenValidationResultTestFactory.java index 4a399bdeeb8553352708e664b61ebbb0757e4dbb..6d0d98c85de2c4445d9fd1dd8d4306a955794329 100644 --- a/token-checker-server/src/test/java/de/ozgcloud/token/CheckTokenResultTestFactory.java +++ b/token-checker-server/src/test/java/de/ozgcloud/token/TokenValidationResultTestFactory.java @@ -20,38 +20,34 @@ package de.ozgcloud.token; -import java.util.List; -import java.util.UUID; - -import com.thedeanda.lorem.LoremIpsum; - import de.ozgcloud.token.TokenValidationResult.TokenValidationResultBuilder; +import de.ozgcloud.token.common.errorHandler.ValidationErrorTestFactory; +import de.ozgcloud.token.common.errorhandling.ValidationError; import lombok.AccessLevel; import lombok.NoArgsConstructor; -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class CheckTokenResultTestFactory { +public class TokenValidationResultTestFactory { - public static final String POSTFACH_ID = UUID.randomUUID().toString(); - public static final String TRUST_LEVEL = "LOW"; - public static final TokenAttribute OTHER_FIELD = TokenAttributeTestFactory.create(); - public static final String ERROR_MESSAGE = LoremIpsum.getInstance().getWords(4); + public static final TokenAttributes TOKEN_ATTRIBUTES = TokenAttributesTestFactory.create(); + public static final ValidationError VALIDATION_ERROR = ValidationErrorTestFactory.create(); - static TokenValidationResult createValid() { - return createBuilder().valid(true).build(); + public static TokenValidationResult createValid() { + return createValidBuilder().valid(true).build(); } - public static TokenValidationResultBuilder createBuilder() { + public static TokenValidationResultBuilder createValidBuilder() { return TokenValidationResult.builder() - .postfachId(POSTFACH_ID) - .trustLevel(TRUST_LEVEL) - .attributes(List.of(OTHER_FIELD)); + .valid(true) + .attributes(TOKEN_ATTRIBUTES); } public static TokenValidationResult createInvalid() { - return createBuilder() + return createInvalidBuilder().build(); + } + + public static TokenValidationResult.TokenValidationResultBuilder createInvalidBuilder() { + return TokenValidationResult.builder() .valid(false) - .errorMesssage(ERROR_MESSAGE) - .build(); + .validationError(VALIDATION_ERROR); } } diff --git a/token-checker-server/src/test/java/de/ozgcloud/token/common/errorHandler/ValidationErrorTestFactory.java b/token-checker-server/src/test/java/de/ozgcloud/token/common/errorHandler/ValidationErrorTestFactory.java new file mode 100644 index 0000000000000000000000000000000000000000..d2da3914d5513bedaf4940318ccbca846c62d763 --- /dev/null +++ b/token-checker-server/src/test/java/de/ozgcloud/token/common/errorHandler/ValidationErrorTestFactory.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 Das Land Schleswig-Holstein vertreten durch den + * Ministerpräsidenten des Landes Schleswig-Holstein + * Staatskanzlei + * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung + * + * Lizenziert unter der EUPL, Version 1.2 oder - sobald + * diese von der Europäischen Kommission genehmigt wurden - + * Folgeversionen der EUPL ("Lizenz"); + * Sie dürfen dieses Werk ausschließlich gemäß + * dieser Lizenz nutzen. + * Eine Kopie der Lizenz finden Sie hier: + * + * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * Sofern nicht durch anwendbare Rechtsvorschriften + * gefordert oder in schriftlicher Form vereinbart, wird + * die unter der Lizenz verbreitete Software "so wie sie + * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN - + * ausdrücklich oder stillschweigend - verbreitet. + * Die sprachspezifischen Genehmigungen und Beschränkungen + * unter der Lizenz sind dem Lizenztext zu entnehmen. + */ + +package de.ozgcloud.token.common.errorHandler; + +import com.thedeanda.lorem.LoremIpsum; + +import de.ozgcloud.token.common.errorhandling.ValidationError; + +public class ValidationErrorTestFactory { + + private static final String ERROR_MESSAGE = LoremIpsum.getInstance().getWords(4); + private static final Exception CAUSE = new Exception(); + + public static ValidationError create() { + return createBuilder().build(); + } + + public static ValidationError.ValidationErrorBuilder createBuilder() { + return ValidationError.builder() + .message(ERROR_MESSAGE) + .cause(CAUSE); + } +} diff --git a/token-checker-server/src/test/java/de/ozgcloud/token/saml/SamlTokenServiceTest.java b/token-checker-server/src/test/java/de/ozgcloud/token/saml/SamlTokenServiceTest.java index 639d2b5e6f26fcbbc0ab7e75fcbcb18e66f0199c..03c85da31396786de3e1f15e8c8cf9c7bf24fd39 100644 --- a/token-checker-server/src/test/java/de/ozgcloud/token/saml/SamlTokenServiceTest.java +++ b/token-checker-server/src/test/java/de/ozgcloud/token/saml/SamlTokenServiceTest.java @@ -23,16 +23,45 @@ */ package de.ozgcloud.token.saml; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Optional; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Spy; +import org.opensaml.core.xml.io.UnmarshallingException; +import org.opensaml.saml.saml2.core.Issuer; +import org.opensaml.saml.saml2.core.Response; +import org.opensaml.saml.saml2.core.impl.ResponseUnmarshaller; +import org.w3c.dom.Document; +import org.w3c.dom.Element; import com.thedeanda.lorem.LoremIpsum; -class SamlTokenServiceTest { +import de.ozgcloud.common.errorhandling.TechnicalException; +import de.ozgcloud.token.TokenAttributes; +import de.ozgcloud.token.TokenAttributesTestFactory; +import de.ozgcloud.token.TokenValidationResult; +import de.ozgcloud.token.TokenValidationResultTestFactory; +import de.ozgcloud.token.common.errorHandler.ValidationErrorTestFactory; +import de.ozgcloud.token.common.errorhandling.TokenVerificationException; +import lombok.SneakyThrows; +import net.shibboleth.utilities.java.support.xml.ParserPool; +import net.shibboleth.utilities.java.support.xml.XMLParserException; - private static final String SAML_TOKEN = LoremIpsum.getInstance().getWords(7); +class SamlTokenServiceTest { @Spy @InjectMocks @@ -40,9 +69,379 @@ class SamlTokenServiceTest { @Mock private SamlServiceRegistry samlServiceRegistry; + @Mock + private ParserPool parserPool; + @Mock + private ResponseUnmarshaller responseUnmarshaller; @Nested class TestValidate { + private static final String SAML_TOKEN = LoremIpsum.getInstance().getWords(7); + + @Mock + private Response parsedToken; + + @Nested + class TestValid { + + private static final TokenValidationResult VALIDATION_RESULT = TokenValidationResultTestFactory.createValid(); + private static final TokenAttributes TOKEN_ATTRIBUTES = TokenAttributesTestFactory.create(); + + @BeforeEach + void init() { + doReturn(parsedToken).when(service).parseToken(anyString()); + doReturn(TOKEN_ATTRIBUTES).when(service).getAttributes(any()); + doReturn(VALIDATION_RESULT).when(service).buildValidTokenResult(any()); + } + + @Test + void shouldCallParseToken() { + validate(); + + verify(service).validate(SAML_TOKEN); + } + + @Test + void shouldCallGetAttributes() { + validate(); + + verify(service).getAttributes(parsedToken); + } + + @Test + void shouldCallBuildValidTokenResult() { + validate(); + + verify(service).buildValidTokenResult(TOKEN_ATTRIBUTES); + } + + @Test + void shouldReturnValidResult() { + var result = validate(); + + assertThat(result).isSameAs(VALIDATION_RESULT); + } + } + + @Nested + class TestInvalid { + + @Mock + private TokenVerificationException exception; + + @DisplayName("should call buildInvalidTokenResult if parseToken throws exception") + @Test + void shouldCallBuildInvalidTokenResult1() { + doThrow(exception).when(service).parseToken(anyString()); + + validate(); + + verify(service).buildInvalidTokenResult(exception); + } + + @DisplayName("should call buildInvalidTokenResult if getAttributes throws exception") + @Test + void shouldCallBuildInvalidTokenResult2() { + doReturn(parsedToken).when(service).parseToken(anyString()); + doThrow(exception).when(service).getAttributes(any()); + + validate(); + + verify(service).buildInvalidTokenResult(exception); + } + + @Test + void shouldReturnInvalidResult() { + doThrow(exception).when(service).parseToken(anyString()); + var invalidTokenResult = TokenValidationResultTestFactory.createInvalid(); + doReturn(invalidTokenResult).when(service).buildInvalidTokenResult(exception); + + var result = validate(); + + assertThat(result).isSameAs(invalidTokenResult); + } + } + + private TokenValidationResult validate() { + return service.validate(SAML_TOKEN); + } + } + + @Nested + class TestParseToken { + + private static final String SAML_TOKEN = LoremIpsum.getInstance().getWords(7); + + @Mock + private Response response; + @Mock + private InputStream tokenStream; + @Mock + private Document tokenDocument; + @Mock + private Element tokenElement; + + @BeforeEach + void init() { + doReturn(tokenStream).when(service).buildInputStream(anyString()); + } + + @Nested + class TestParsingSuccessfully { + @Captor + private ArgumentCaptor<InputStream> tokenStreamCaptor; + + @SneakyThrows + @BeforeEach + void init() { + when(tokenDocument.getDocumentElement()).thenReturn(tokenElement); + when(parserPool.parse(any(InputStream.class))).thenReturn(tokenDocument); + } + + @Test + void shouldCallBuildInputStream() { + parseToken(); + + verify(service).buildInputStream(SAML_TOKEN); + } + + @SneakyThrows + @Test + void shouldCallParserPool() { + parseToken(); + + verify(parserPool).parse(tokenStreamCaptor.capture()); + assertThat(tokenStreamCaptor.getValue()).isSameAs(tokenStream); + } + + @SneakyThrows + @Test + void shouldCallUnmarshall() { + parseToken(); + + verify(responseUnmarshaller).unmarshall(tokenElement); + } + + @SneakyThrows + @Test + void shouldReturnResponse() { + doReturn(response).when(responseUnmarshaller).unmarshall(any()); + + var result = parseToken(); + + assertThat(result).isSameAs(response); + } + } + + @Nested + class TestParsingFails { + + @SneakyThrows + @DisplayName("should throw TokenVerificationException on IOException") + @Test + void shouldThrowOnIOException() { + when(tokenDocument.getDocumentElement()).thenReturn(tokenElement); + when(parserPool.parse(any(InputStream.class))).thenReturn(tokenDocument); + var exception = new IOException(); + doThrow(exception).when(tokenStream).close(); + + Assertions.assertThatThrownBy(TestParseToken.this::parseToken) + .isInstanceOf(TokenVerificationException.class) + .hasCause(exception); + } + + @SneakyThrows + @DisplayName("should throw TokenVerificationException on XMLParserException") + @Test + void shouldThrowOnXMLParserException() { + var exception = new XMLParserException(); + doThrow(exception).when(parserPool).parse(any(InputStream.class)); + + Assertions.assertThatThrownBy(TestParseToken.this::parseToken) + .isInstanceOf(TokenVerificationException.class) + .hasCause(exception); + } + + @SneakyThrows + @DisplayName("should throw TokenVerificationException on UnmarshallingException") + @Test + void shouldThrowOnUnmarshallingException() { + when(tokenDocument.getDocumentElement()).thenReturn(tokenElement); + when(parserPool.parse(any(InputStream.class))).thenReturn(tokenDocument); + var exception = new UnmarshallingException(); + doThrow(exception).when(responseUnmarshaller).unmarshall(any()); + + Assertions.assertThatThrownBy(TestParseToken.this::parseToken) + .isInstanceOf(TokenVerificationException.class) + .hasCause(exception); + } + } + + private Response parseToken() { + return service.parseToken(SAML_TOKEN); + } + } + + @Nested + class TestBuildInputStream { + + private static final String SAML_TOKEN = LoremIpsum.getInstance().getWords(7); + + @SneakyThrows + @Test + void shouldReturnInputStream() { + var result = service.buildInputStream(SAML_TOKEN); + + assertThat(result.readAllBytes()).isEqualTo(SAML_TOKEN.getBytes()); + } + } + + @Nested + class TestGetAttributes { + + private static final String TOKEN_ISSUER = LoremIpsum.getInstance().getWords(3); + private static final TokenAttributes TOKEN_ATTRIBUTES = TokenAttributesTestFactory.create(); + + @Mock + private Response token; + @Mock + private SamlAttributeService samlAttributeService; + + @BeforeEach + void init() { + doReturn(TOKEN_ISSUER).when(service).getTokenIssuer(any()); + doReturn(samlAttributeService).when(service).getSamlAttributeService(any()); + doReturn(TOKEN_ATTRIBUTES).when(samlAttributeService).getAttributes(any()); + } + + @Test + void shouldCallGetTokenIssuer() { + getAttributes(); + + verify(service).getTokenIssuer(token); + } + + @Test + void shouldCallGetValidationService() { + getAttributes(); + + verify(service).getSamlAttributeService(TOKEN_ISSUER); + } + + @Test + void shouldCallGetSamlAttributeService() { + getAttributes(); + + verify(service).getSamlAttributeService(TOKEN_ISSUER); + } + + @Test + void shouldCallGetAttributes() { + getAttributes(); + + verify(samlAttributeService).getAttributes(token); + } + + @Test + void shouldReturnTokenAttributes() { + var result = getAttributes(); + + assertThat(result).isSameAs(TOKEN_ATTRIBUTES); + } + + private TokenAttributes getAttributes() { + return service.getAttributes(token); + } + } + + @Nested + class TestGetTokenIssuer { + + private static final String TOKEN_ISSUER = LoremIpsum.getInstance().getWords(3); + + @Mock + private Response token; + @Mock + private Issuer issuer; + + @Test + void shouldReturnTokenIssuer() { + when(token.getIssuer()).thenReturn(issuer); + when(issuer.getValue()).thenReturn(TOKEN_ISSUER); + + var result = service.getTokenIssuer(token); + + assertThat(result).isEqualTo(TOKEN_ISSUER); + } + + @Test + void shouldThrowOnNoIssuer() { + when(token.getIssuer()).thenReturn(null); + + assertThatThrownBy(() -> service.getTokenIssuer(token)).isInstanceOf(TokenVerificationException.class); + } + } + + @Nested + class TestGetSamlAttributeService { + + private static final String TOKEN_ISSUER = LoremIpsum.getInstance().getWords(1); + + @Mock + private SamlAttributeService samlAttributeService; + + @Test + void shouldCallGetService() { + when(samlServiceRegistry.getService(anyString())).thenReturn(Optional.of(samlAttributeService)); + + getSamlAttributeService(); + + verify(samlServiceRegistry).getService(TOKEN_ISSUER); + } + + @Test + void shouldReturnService() { + when(samlServiceRegistry.getService(anyString())).thenReturn(Optional.of(samlAttributeService)); + + var result = getSamlAttributeService(); + + assertThat(result).isSameAs(samlAttributeService); + } + + @Test + void shouldThrowOnNoService() { + when(samlServiceRegistry.getService(anyString())).thenReturn(Optional.empty()); + + assertThatThrownBy(this::getSamlAttributeService).isInstanceOf(TechnicalException.class); + } + + private SamlAttributeService getSamlAttributeService() { + return service.getSamlAttributeService(TOKEN_ISSUER); + } + } + + @Nested + class TestBuildValidTokenResult { + + @Test + void shouldBuildResult() { + var result = service.buildValidTokenResult(TokenAttributesTestFactory.create()); + + assertThat(result).usingRecursiveComparison().isEqualTo(TokenValidationResultTestFactory.createValid()); + } + } + + @Nested + class TestBuildInvalidTokenResult { + + @Test + void shouldBuildResult() { + var exception = new TokenVerificationException("msg", List.of(ValidationErrorTestFactory.create())); + + var result = service.buildInvalidTokenResult(exception); + + assertThat(result).usingRecursiveComparison().isEqualTo(TokenValidationResultTestFactory.createInvalid()); + } } } \ No newline at end of file