diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml
index 0c473225..f246020f 100644
--- a/.github/workflows/maven-release.yml
+++ b/.github/workflows/maven-release.yml
@@ -52,7 +52,7 @@ jobs:
run: |
git checkout -b release
./mvnw -B -Prelease release:clean release:prepare -DreleaseVersion=${{steps.metadata.outputs.current-version}} -DdevelopmentVersion=${{steps.metadata.outputs.next-version}}
- ./mvnw -B -Prelease javadoc:aggregate site:site site:stage
+ ./mvnw -B -Prelease,javadoc site:site site:stage
git checkout ${{vars.GITHUB_BASE_REF}}
git rebase release
./mvnw -B -Prelease release:perform -DskipTests
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
index 5c1e472e..afb7af95 100644
--- a/.github/workflows/maven.yml
+++ b/.github/workflows/maven.yml
@@ -103,8 +103,7 @@ jobs:
- name: Deploy Snapshot with Maven, version ${{ steps.project.outputs.version }}
if: ${{endsWith(steps.project.outputs.version, '-SNAPSHOT')}}
run: |
- ./mvnw -B -Prelease-snapshot javadoc:aggregate
- ./mvnw -B -Prelease-snapshot deploy -Dmaven.test.skip=true
+ ./mvnw -B -Prelease-snapshot,javadoc deploy -Dmaven.test.skip=true
env:
MAVEN_USERNAME: ${{ secrets.OSS_SONATYPE_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.OSS_SONATYPE_PASSWORD }}
diff --git a/README.adoc b/README.adoc
index addc09a5..648c1caf 100644
--- a/README.adoc
+++ b/README.adoc
@@ -698,3 +698,32 @@ assertEquals("null [5, 6]", MoreStrings.lenientFormat(null, 5, 6));
assertEquals("null", MoreStrings.lenientFormat("%s", (Object) null));
assertEquals("(Object[])null", MoreStrings.lenientFormat("%s", (Object[]) null));
----
+
+[[iocuiutilnet]]
+=== de.cuioss.tools.net
+
+Provides utilities for network operations, including URL handling, internet address support, SSL, and HTTP helpers.
+
+==== de.cuioss.tools.net.http
+
+HTTP-related utilities, such as:
+
+* HttpHandler – HTTP request/response handling
+* HttpStatusFamily – HTTP status family detection
+* SecureSSLContextProvider – Secure SSL context for HTTP
+
+==== de.cuioss.tools.net.ssl
+
+SSL-related helpers, including:
+
+* KeyStoreProvider – KeyStore management
+* KeyMaterialHolder – Key material handling
+* KeyAlgorithm – Supported algorithms
+* KeyStoreType – KeyStore types
+
+==== de.cuioss.tools.net (core)
+
+* UrlHelper – URL manipulation utilities
+* UrlParameter – URL parameter handling
+* ParameterFilter – Parameter filtering
+* IDNInternetAddress – IDN support and internationalized domain handling
diff --git a/pom.xml b/pom.xml
index 05b9ec10..ae155c5e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,7 +7,7 @@
+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *
+ * https://www.apache.org/licenses/LICENSE-2.0 + *
+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.tools.net.http; + +import de.cuioss.tools.logging.CuiLogger; +import de.cuioss.tools.string.MoreStrings; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +import javax.net.ssl.SSLContext; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * A common wrapper around {@link HttpClient} that provides a builder for collecting + * HTTP request attributes and methods for executing HTTP requests. + * It provides a consistent way to configure and execute HTTP requests with proper + * SSL context handling and timeout configuration. + * Contract: + *
+ * HttpHandler handler = HttpHandler.builder()
+ * .uri("https://example.com/api")
+ * .requestTimeoutSeconds(10)
+ * .build();
+ *
+ */
+@EqualsAndHashCode
+@ToString
+@Builder(builderClassName = "HttpHandlerBuilder", access = AccessLevel.PRIVATE)
+@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+public final class HttpHandler {
+
+ private static final CuiLogger LOGGER = new CuiLogger(HttpHandler.class);
+ public static final int DEFAULT_REQUEST_TIMEOUT_SECONDS = 10;
+
+ /**
+ * The URI to be used for HTTP requests.
+ */
+ @Getter
+ private final URI uri;
+
+ /**
+ * The URL representation of the URI.
+ */
+ @Getter
+ private final URL url;
+
+ /**
+ * The SSL context to be used for HTTPS connections.
+ */
+ @Getter
+ private final SSLContext sslContext;
+
+ /**
+ * The timeout in seconds for HTTP requests.
+ */
+ @Getter
+ private final int requestTimeoutSeconds;
+
+
+ /**
+ * Returns a new builder for creating a {@link HttpHandler} instance.
+ *
+ * @return A new builder instance.
+ */
+ public static HttpHandlerBuilder builder() {
+ return new HttpHandlerBuilder();
+ }
+
+ /**
+ * Creates a pre-configured {@link HttpRequest.Builder} for the URI contained in this handler.
+ * The builder is configured with the timeout from this handler.
+ *
+ * @return A pre-configured {@link HttpRequest.Builder}
+ */
+ public HttpRequest.Builder requestBuilder() {
+ return HttpRequest.newBuilder()
+ .uri(uri)
+ .timeout(Duration.ofSeconds(requestTimeoutSeconds));
+ }
+
+ /**
+ * Creates a pre-configured {@link HttpHandlerBuilder} with the same configuration as this handler.
+ * The builder is configured with the timeout and sslContext from this handler.
+ *
+ * This method allows creating a new builder based on the current handler's configuration, + * which can be used to create a new handler with modified URL.
+ * + * @return A pre-configured {@link HttpHandlerBuilder} with the same timeout as this handler + */ + public HttpHandlerBuilder asBuilder() { + return builder() + .requestTimeoutSeconds(requestTimeoutSeconds) + .sslContext(sslContext); + } + + /** + * Pings the URI using the HEAD method and returns the HTTP status code. + * + * @return The HTTP status code family, or {@link HttpStatusFamily#UNKNOWN} if an error occurred + */ + @SuppressWarnings("try") // HttpClient implements AutoCloseable in Java 17 but doesn't need to be closed + public HttpStatusFamily pingHead() { + return pingWithMethod("HEAD", HttpRequest.BodyPublishers.noBody()); + } + + /** + * Pings the URI using the GET method and returns the HTTP status code. + * + * @return The HTTP status code family, or {@link HttpStatusFamily#UNKNOWN} if an error occurred + */ + @SuppressWarnings("try") // HttpClient implements AutoCloseable in Java 17 but doesn't need to be closed + public HttpStatusFamily pingGet() { + return pingWithMethod("GET", HttpRequest.BodyPublishers.noBody()); + } + + /** + * Pings the URI using the specified HTTP method and returns the HTTP status code. + * + * @param method The HTTP method to use (e.g., "HEAD", "GET") + * @param bodyPublisher The body publisher to use for the request + * @return The HTTP status code family, or {@link HttpStatusFamily#UNKNOWN} if an error occurred + */ + @SuppressWarnings("try") // HttpClient implements AutoCloseable in Java 17 but doesn't need to be closed + private HttpStatusFamily pingWithMethod(String method, HttpRequest.BodyPublisher bodyPublisher) { + try { + HttpClient httpClient = createHttpClient(); + HttpRequest request = requestBuilder() + .method(method, bodyPublisher) + .build(); + + HttpResponse+ * Note: If both URI and URL are set, the URI takes precedence. + *
+ * + * @param uri The URI to be used for HTTP requests. + * Must not be null. + * @return This builder instance. + */ + public HttpHandlerBuilder uri(URI uri) { + this.uri = uri; + return this; + } + + /** + * Sets the URL as a string. + *+ * Note: This method is provided for backward compatibility. + * Consider using {@link #uri(String)} instead. + *
+ * + * @param urlString The string representation of the URL. + * Must not be null or empty. + * @return This builder instance. + * @throws IllegalArgumentException if the URL string is null, empty, or malformed + * (thrown during the {@link #build()} method execution, + * not by this setter method) + */ + public HttpHandlerBuilder url(String urlString) { + this.urlString = urlString; + return this; + } + + /** + * Sets the URL directly. + *+ * Note: This method is provided for backward compatibility. + * Consider using {@link #uri(URI)} instead. + *
+ *+ * If both URI and URL are set, the URI takes precedence. + *
+ * + * @param url The URL to be used for HTTP requests. + * Must not be null. + * @return This builder instance. + */ + public HttpHandlerBuilder url(URL url) { + this.url = url; + return this; + } + + /** + * Sets the SSL context to use for HTTPS connections. + *+ * If not set, a default secure SSL context will be created. + *
+ * + * @param sslContext The SSL context to use. + * @return This builder instance. + */ + public HttpHandlerBuilder sslContext(SSLContext sslContext) { + this.sslContext = sslContext; + return this; + } + + /** + * Sets the TLS versions configuration. + * + * @param secureSSLContextProvider The TLS versions configuration to use. + * @return This builder instance. + */ + public HttpHandlerBuilder tlsVersions(SecureSSLContextProvider secureSSLContextProvider) { + this.secureSSLContextProvider = secureSSLContextProvider; + return this; + } + + /** + * Sets the timeout in seconds for HTTP requests. + *+ * If not set, a default timeout of 10 seconds will be used. + *
+ * + * @param requestTimeoutSeconds The request timeout in seconds. + * Must be positive. + * @return This builder instance. + */ + public HttpHandlerBuilder requestTimeoutSeconds(int requestTimeoutSeconds) { + this.requestTimeoutSeconds = requestTimeoutSeconds; + return this; + } + + /** + * Builds a new {@link HttpHandler} instance with the configured parameters. + * + * @return A new {@link HttpHandler} instance. + * @throws IllegalArgumentException If any parameter is invalid. + */ + public HttpHandler build() { + // Resolve the URI from the provided inputs + resolveUri(); + + // Validate request timeout + int actualRequestTimeoutSeconds = requestTimeoutSeconds != null ? + requestTimeoutSeconds : DEFAULT_REQUEST_TIMEOUT_SECONDS; + if (actualRequestTimeoutSeconds <= 0) { + throw new IllegalArgumentException("Request timeout must be positive"); + } + + // Convert the URI to a URL + URL verifiedUrl; + try { + verifiedUrl = uri.toURL(); + } catch (MalformedURLException e) { + throw new IllegalStateException("Failed to convert URI to URL: " + uri, e); + } + + // Create a secure SSL context if the URI uses HTTPS or if explicitly provided + SSLContext secureContext = null; + if ("https".equalsIgnoreCase(uri.getScheme()) || secureSSLContextProvider != null || sslContext != null) { + SecureSSLContextProvider actualSecureSSLContextProvider = secureSSLContextProvider != null ? + secureSSLContextProvider : new SecureSSLContextProvider(); + secureContext = actualSecureSSLContextProvider.getOrCreateSecureSSLContext(sslContext); + } + + return new HttpHandler(uri, verifiedUrl, secureContext, actualRequestTimeoutSeconds); + } + + /** + * Resolves the URI from the provided inputs. + * Priority: 1. uri, 2. url, 3. urlString + */ + private void resolveUri() { + // If URI is already set, use it + if (uri != null) { + return; + } + + // If URL is set, convert it to URI + if (url != null) { + try { + uri = url.toURI(); + return; + } catch (Exception e) { + throw new IllegalArgumentException("Invalid URL: " + url, e); + } + } + + // If urlString is set, convert it to URI + if (!MoreStrings.isBlank(urlString)) { + try { + // Check if the URL has a scheme, if not prepend https:// + String urlToUse = urlString; + if (!urlToUse.matches("^[a-zA-Z][a-zA-Z0-9+.-]*:.*")) { + LOGGER.debug(() -> "URL missing scheme, prepending https:// to %s".formatted(urlString)); + urlToUse = "https://" + urlToUse; + } + + uri = URI.create(urlToUse); + return; + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid URI: " + urlString, e); + } + } + + // If we get here, no valid URI source was provided + throw new IllegalArgumentException("URI must not be null or empty."); + } + } +} diff --git a/src/main/java/de/cuioss/tools/net/http/HttpStatusFamily.java b/src/main/java/de/cuioss/tools/net/http/HttpStatusFamily.java new file mode 100644 index 00000000..432187c3 --- /dev/null +++ b/src/main/java/de/cuioss/tools/net/http/HttpStatusFamily.java @@ -0,0 +1,173 @@ +/* + * Copyright 2023 the original author or authors. + *+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *
+ * https://www.apache.org/licenses/LICENSE-2.0 + *
+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.tools.net.http; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Enum representing HTTP status code families as defined in RFC 7231. + *
+ * The HTTP status codes are grouped into five classes: + *
+ * This enum provides methods to check if a status code belongs to a particular family + * and to get the family for a given status code. + */ +@RequiredArgsConstructor +public enum HttpStatusFamily { + + /** + * 1xx: Informational - Request received, a continuing process. + */ + INFORMATIONAL(100, 199, "Informational"), + + /** + * 2xx: Success - The action was successfully received, understood, and accepted. + */ + SUCCESS(200, 299, "Success"), + + /** + * 3xx: Redirection - Further action needs to be taken to complete the request. + */ + REDIRECTION(300, 399, "Redirection"), + + /** + * 4xx: Client Error - The request contains bad syntax or cannot be fulfilled. + */ + CLIENT_ERROR(400, 499, "Client Error"), + + /** + * 5xx: Server Error - The server failed to fulfill an apparently valid request. + */ + SERVER_ERROR(500, 599, "Server Error"), + + /** + * Unknown - Used for status codes outside the standard ranges or for error conditions. + */ + UNKNOWN(-1, -1, "Unknown"); + + @Getter + private final int minCode; + + @Getter + private final int maxCode; + + @Getter + private final String description; + + /** + * Checks if the given status code belongs to this family. + * + * @param statusCode the HTTP status code to check + * @return true if the status code belongs to this family, false otherwise + */ + public boolean contains(int statusCode) { + if (this == UNKNOWN) { + return statusCode < 100 || statusCode > 599; + } + return statusCode >= minCode && statusCode <= maxCode; + } + + /** + * Gets the HTTP status code family for the given status code. + * + * @param statusCode the HTTP status code + * @return the corresponding HttpStatusFamily family + */ + public static HttpStatusFamily fromStatusCode(int statusCode) { + for (HttpStatusFamily family : values()) { + if (family.contains(statusCode)) { + return family; + } + } + return UNKNOWN; + } + + /** + * Checks if the given status code indicates a successful response (2xx). + * + * @param statusCode the HTTP status code to check + * @return true if the status code indicates success, false otherwise + */ + public static boolean isSuccess(int statusCode) { + return SUCCESS.contains(statusCode); + } + + /** + * Checks if the given status code indicates a client error (4xx). + * + * @param statusCode the HTTP status code to check + * @return true if the status code indicates a client error, false otherwise + */ + public static boolean isClientError(int statusCode) { + return CLIENT_ERROR.contains(statusCode); + } + + /** + * Checks if the given status code indicates a server error (5xx). + * + * @param statusCode the HTTP status code to check + * @return true if the status code indicates a server error, false otherwise + */ + public static boolean isServerError(int statusCode) { + return SERVER_ERROR.contains(statusCode); + } + + /** + * Checks if the given status code indicates a redirection (3xx). + * + * @param statusCode the HTTP status code to check + * @return true if the status code indicates a redirection, false otherwise + */ + public static boolean isRedirection(int statusCode) { + return REDIRECTION.contains(statusCode); + } + + /** + * Checks if the given status code indicates an informational response (1xx). + * + * @param statusCode the HTTP status code to check + * @return true if the status code indicates an informational response, false otherwise + */ + public static boolean isInformational(int statusCode) { + return INFORMATIONAL.contains(statusCode); + } + + /** + * Checks if the given status code is a valid HTTP status code (100-599). + * + * @param statusCode the HTTP status code to check + * @return true if the status code is valid, false otherwise + */ + public static boolean isValid(int statusCode) { + return statusCode >= 100 && statusCode <= 599; + } + + @Override + public String toString() { + if (this == UNKNOWN) { + return description; + } + return "%s (%d-%d)".formatted(description, minCode, maxCode); + } +} \ No newline at end of file diff --git a/src/main/java/de/cuioss/tools/net/http/SecureSSLContextProvider.java b/src/main/java/de/cuioss/tools/net/http/SecureSSLContextProvider.java new file mode 100644 index 00000000..026f0f42 --- /dev/null +++ b/src/main/java/de/cuioss/tools/net/http/SecureSSLContextProvider.java @@ -0,0 +1,242 @@ +/* + * Copyright 2025 the original author or authors. + *
+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *
+ * https://www.apache.org/licenses/LICENSE-2.0 + *
+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.tools.net.http; + +import de.cuioss.tools.collect.CollectionLiterals; +import de.cuioss.tools.logging.CuiLogger; +import lombok.Getter; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Set; + +/** + * Provider for secure SSL contexts used in HTTPS communications. + *
+ * This class enforces secure TLS versions when establishing connections to JWKS endpoints + * and other services. It ensures that only modern, secure TLS protocols are used: + *
+ * The class prevents the use of insecure, deprecated protocols: + *
+ * For more details on the security aspects, see the
+ * Security Specification
+ *
+ * @author Oliver Wolff
+ * @since 1.0
+ */
+public class SecureSSLContextProvider {
+
+ private static final CuiLogger LOGGER = new CuiLogger(SecureSSLContextProvider.class);
+
+ /**
+ * TLS version 1.2 - Secure
+ */
+ public static final String TLS_V1_2 = "TLSv1.2";
+
+ /**
+ * TLS version 1.3 - Secure
+ */
+ public static final String TLS_V1_3 = "TLSv1.3";
+
+ /**
+ * Generic TLS - Secure if implemented correctly by the JVM
+ */
+ public static final String TLS = "TLS";
+
+ /**
+ * Default secure TLS version to use when creating a new context
+ */
+ public static final String DEFAULT_TLS_VERSION = TLS_V1_2;
+
+ /**
+ * TLS version 1.0 - Insecure, deprecated
+ */
+ public static final String TLS_V1_0 = "TLSv1.0";
+
+ /**
+ * TLS version 1.1 - Insecure, deprecated
+ */
+ public static final String TLS_V1_1 = "TLSv1.1";
+
+ /**
+ * SSL version 3 - Insecure, deprecated
+ */
+ public static final String SSL_V3 = "SSLv3";
+
+ /**
+ * Set of allowed (secure) TLS versions
+ */
+ public static final Set
+ * For TLS_V1_2 and TLS_V1_3, the comparison is based on the version number.
+ * For TLS (generic), it's considered secure if it's in the allowed versions set.
+ *
+ * @param protocol the protocol to check
+ * @return true if the protocol is a secure TLS version, false otherwise
+ */
+ public boolean isSecureTlsVersion(String protocol) {
+ if (protocol == null) {
+ return false;
+ }
+
+ if (!ALLOWED_TLS_VERSIONS.contains(protocol)) {
+ return false;
+ }
+
+ // If the minimum is TLS_V1_3, only TLS_V1_3 and TLS are considered secure
+ if (TLS_V1_3.equals(minimumTlsVersion)) {
+ return TLS_V1_3.equals(protocol) || TLS.equals(protocol);
+ }
+
+ // If the minimum is TLS_V1_2, all allowed versions are secure
+ return true;
+ }
+
+ /**
+ * Creates a secure SSLContext configured with the minimum TLS version set for this instance.
+ *
+ * This method:
+ *
+ * The resulting SSLContext is configured to trust the certificates in the JVM's default trust store
+ * and does not perform client authentication (no KeyManager is provided).
+ *
+ * @return a configured SSLContext that uses a secure TLS protocol version
+ * @throws NoSuchAlgorithmException if the specified protocol or trust manager algorithm is not available
+ * @throws KeyStoreException if there's an issue accessing the default trust store
+ * @throws KeyManagementException if there's an issue initializing the SSLContext
+ */
+ public SSLContext createSecureSSLContext() throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
+ // Create a secure SSL context with the minimum TLS version
+ SSLContext secureContext = SSLContext.getInstance(minimumTlsVersion);
+ TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ trustManagerFactory.init((KeyStore) null);
+ TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
+ secureContext.init(null, trustManagers, new SecureRandom());
+ return secureContext;
+ }
+
+ /**
+ * Validates the provided SSLContext and returns a secure SSLContext.
+ *
+ * This method:
+ *
+ * See {@link de.cuioss.tools.net.http.HttpHandler}, {@link de.cuioss.tools.net.http.HttpStatusFamily},
+ * and {@link de.cuioss.tools.net.http.SecureSSLContextProvider} for details.
+ */
+package de.cuioss.tools.net.http;
diff --git a/src/main/java/de/cuioss/tools/net/package-info.java b/src/main/java/de/cuioss/tools/net/package-info.java
index 67939207..c728c0b0 100644
--- a/src/main/java/de/cuioss/tools/net/package-info.java
+++ b/src/main/java/de/cuioss/tools/net/package-info.java
@@ -31,6 +31,13 @@
*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.tools.net.http;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import javax.net.ssl.SSLContext;
+import java.net.URI;
+import java.net.URL;
+import java.net.http.HttpRequest;
+import java.time.Duration;
+
+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 static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test for {@link HttpHandler}
+ */
+class HttpHandlerTest {
+
+ private static final String VALID_URL = "https://example.com";
+ private static final int CUSTOM_TIMEOUT = 20;
+
+ @Nested
+ @DisplayName("Builder Tests")
+ class BuilderTests {
+
+ @Test
+ @DisplayName("Should build with URL string")
+ void shouldBuildWithUrlString() {
+ HttpHandler handler = HttpHandler.builder()
+ .url(VALID_URL)
+ .build();
+
+ assertNotNull(handler);
+ assertEquals(VALID_URL, handler.getUrl().toString());
+ assertEquals(URI.create(VALID_URL), handler.getUri());
+ assertEquals(HttpHandler.DEFAULT_REQUEST_TIMEOUT_SECONDS, handler.getRequestTimeoutSeconds());
+ }
+
+ @Test
+ @DisplayName("Should build with URL object")
+ void shouldBuildWithUrlObject() throws Exception {
+ URL url = URI.create(VALID_URL).toURL();
+ HttpHandler handler = HttpHandler.builder()
+ .url(url)
+ .build();
+
+ assertNotNull(handler);
+ assertEquals(url, handler.getUrl());
+ assertEquals(url.toURI(), handler.getUri());
+ }
+
+ @Test
+ @DisplayName("Should build with URI string")
+ void shouldBuildWithUriString() {
+ HttpHandler handler = HttpHandler.builder()
+ .uri(VALID_URL)
+ .build();
+
+ assertNotNull(handler);
+ assertEquals(VALID_URL, handler.getUrl().toString());
+ assertEquals(URI.create(VALID_URL), handler.getUri());
+ assertEquals(HttpHandler.DEFAULT_REQUEST_TIMEOUT_SECONDS, handler.getRequestTimeoutSeconds());
+ }
+
+ @Test
+ @DisplayName("Should build with URI object")
+ void shouldBuildWithUriObject() {
+ URI uri = URI.create(VALID_URL);
+ HttpHandler handler = HttpHandler.builder()
+ .uri(uri)
+ .build();
+
+ assertNotNull(handler);
+ assertEquals(uri, handler.getUri());
+ assertEquals(VALID_URL, handler.getUrl().toString());
+ }
+
+ @Test
+ @DisplayName("Should build with custom timeout")
+ void shouldBuildWithCustomTimeout() {
+ HttpHandler handler = HttpHandler.builder()
+ .url(VALID_URL)
+ .requestTimeoutSeconds(CUSTOM_TIMEOUT)
+ .build();
+
+ assertNotNull(handler);
+ assertEquals(CUSTOM_TIMEOUT, handler.getRequestTimeoutSeconds());
+ }
+
+ @Test
+ @DisplayName("Should build with SSL context")
+ void shouldBuildWithSslContext() throws Exception {
+ SSLContext sslContext = SSLContext.getDefault();
+ HttpHandler handler = HttpHandler.builder()
+ .url(VALID_URL)
+ .sslContext(sslContext)
+ .build();
+
+ assertNotNull(handler);
+ assertNotNull(handler.getSslContext());
+ }
+
+ @Test
+ @DisplayName("Should build with SecureSSLContextProvider")
+ void shouldBuildWithSecureSSLContextProvider() {
+ SecureSSLContextProvider provider = new SecureSSLContextProvider();
+ HttpHandler handler = HttpHandler.builder()
+ .url(VALID_URL)
+ .tlsVersions(provider)
+ .build();
+
+ assertNotNull(handler);
+ assertNotNull(handler.getSslContext());
+ }
+
+ @Test
+ @DisplayName("Should prepend https:// to URL without scheme")
+ void shouldPrependHttpsToUrlWithoutScheme() {
+ String urlWithoutScheme = "example.com";
+ HttpHandler handler = HttpHandler.builder()
+ .url(urlWithoutScheme)
+ .build();
+
+ assertNotNull(handler);
+ assertEquals("https://example.com", handler.getUrl().toString());
+ assertEquals(URI.create("https://example.com"), handler.getUri());
+
+ // Note: We're not testing the log message here as it's an implementation detail
+ // and the test framework might have issues with capturing it
+ }
+
+ @Test
+ @DisplayName("Should throw exception for null URL/URI")
+ void shouldThrowExceptionForNullUrl() {
+ var builder = HttpHandler.builder();
+ var exception = assertThrows(IllegalArgumentException.class, builder::build);
+
+ assertEquals("URI must not be null or empty.", exception.getMessage());
+ }
+
+ @Test
+ @DisplayName("Should throw exception for empty URL")
+ void shouldThrowExceptionForEmptyUrl() {
+ HttpHandler.HttpHandlerBuilder url = HttpHandler.builder().url("");
+ var exception = assertThrows(IllegalArgumentException.class, url::build);
+
+ assertEquals("URI must not be null or empty.", exception.getMessage());
+ }
+
+ @Test
+ @DisplayName("Should throw exception for invalid URL")
+ void shouldThrowExceptionForInvalidUrl() {
+ // Use a URL with illegal characters in the host part
+ var url = HttpHandler.builder().url("http://invalid url with spaces.com");
+ var exception = assertThrows(IllegalArgumentException.class, url::build);
+
+ assertTrue(exception.getMessage().startsWith("Invalid URI: http://invalid url with spaces.com"));
+ }
+
+ @Test
+ @DisplayName("Should throw exception for negative timeout")
+ void shouldThrowExceptionForNegativeTimeout() {
+ var builder = HttpHandler.builder().url(VALID_URL).requestTimeoutSeconds(-1);
+ var exception = assertThrows(IllegalArgumentException.class, builder::build);
+
+ assertEquals("Request timeout must be positive", exception.getMessage());
+ }
+
+ @Test
+ @DisplayName("Should throw exception for zero timeout")
+ void shouldThrowExceptionForZeroTimeout() {
+ var builder = HttpHandler.builder().url(VALID_URL).requestTimeoutSeconds(0);
+ var exception = assertThrows(IllegalArgumentException.class, builder::build);
+
+ assertEquals("Request timeout must be positive", exception.getMessage());
+ }
+
+ @Test
+ @DisplayName("Should automatically create SSL context for HTTPS URLs")
+ void shouldAutomaticallyCreateSslContextForHttpsUrls() {
+ HttpHandler handler = HttpHandler.builder()
+ .url("https://example.com")
+ .build();
+
+ assertNotNull(handler);
+ assertNotNull(handler.getSslContext(), "SSL context should be automatically created for HTTPS URLs");
+ }
+
+ @Test
+ @DisplayName("Should not create SSL context for HTTP URLs if not explicitly provided")
+ void shouldNotCreateSslContextForHttpUrls() {
+ HttpHandler handler = HttpHandler.builder()
+ .url("http://example.com")
+ .build();
+
+ assertNotNull(handler);
+ assertNull(handler.getSslContext(), "SSL context should not be created for HTTP URLs if not explicitly provided");
+ }
+
+ @Test
+ @DisplayName("URI should take precedence over URL when both are set")
+ void uriShouldTakePrecedenceOverUrl() throws Exception {
+ URI uri = URI.create("https://example.org");
+ URL url = URI.create("https://example.com").toURL();
+
+ HttpHandler handler = HttpHandler.builder()
+ .url(url)
+ .uri(uri)
+ .build();
+
+ assertNotNull(handler);
+ assertEquals(uri, handler.getUri());
+ assertEquals("https://example.org", handler.getUrl().toString());
+ }
+ }
+
+ @Nested
+ @DisplayName("Request Builder Tests")
+ class RequestBuilderTests {
+
+ @Test
+ @DisplayName("Should create request builder with correct URI")
+ void shouldCreateRequestBuilderWithCorrectUri() {
+ HttpHandler handler = HttpHandler.builder()
+ .url(VALID_URL)
+ .build();
+
+ HttpRequest.Builder builder = handler.requestBuilder();
+
+ assertNotNull(builder);
+
+ // Build a request to check the URI
+ HttpRequest request = builder.GET().build();
+ assertEquals(URI.create(VALID_URL), request.uri());
+ }
+
+ @Test
+ @DisplayName("Should create request builder with correct timeout")
+ void shouldCreateRequestBuilderWithCorrectTimeout() {
+ HttpHandler handler = HttpHandler.builder()
+ .url(VALID_URL)
+ .requestTimeoutSeconds(CUSTOM_TIMEOUT)
+ .build();
+
+ HttpRequest.Builder builder = handler.requestBuilder();
+
+ assertNotNull(builder);
+
+ // Build a request to check the timeout
+ HttpRequest request = builder.GET().build();
+ assertTrue(request.timeout().isPresent());
+ assertEquals(Duration.ofSeconds(CUSTOM_TIMEOUT), request.timeout().get());
+ }
+ }
+
+ @Nested
+ @DisplayName("Ping Tests")
+ class PingTests {
+
+ // Note: These tests don't actually make HTTP requests
+ // They just verify the method signatures and error handling
+ // Real HTTP requests would require mocking or integration tests
+
+ @Test
+ @DisplayName("pingHead should return UNKNOWN for unreachable URL")
+ void pingHeadShouldReturnUnknownForUnreachableUrl() {
+ HttpHandler handler = HttpHandler.builder()
+ .url("https://non-existent-domain-12345.example")
+ .build();
+
+ HttpStatusFamily statusCode = handler.pingHead();
+ assertEquals(HttpStatusFamily.UNKNOWN, statusCode);
+ }
+
+ @Test
+ @DisplayName("pingGet should return UNKNOWN for unreachable URL")
+ void pingGetShouldReturnUnknownForUnreachableUrl() {
+ HttpHandler handler = HttpHandler.builder()
+ .url("https://non-existent-domain-12345.example")
+ .build();
+
+ HttpStatusFamily statusCode = handler.pingGet();
+ assertEquals(HttpStatusFamily.UNKNOWN, statusCode);
+ }
+
+ @Test
+ @DisplayName("Should have SSL context for HTTPS URLs")
+ void shouldHaveSslContextForHttpsUrls() {
+ // This test verifies that the SSL context is always created for HTTPS URLs
+ // which ensures that the exception in createHttpClient will never be thrown
+ // in normal operation
+ HttpHandler handler = HttpHandler.builder()
+ .url("https://example.com")
+ .build();
+
+ assertNotNull(handler.getSslContext(),
+ "SSL context should be automatically created for HTTPS URLs to prevent exceptions in createHttpClient");
+ }
+ }
+
+ @Nested
+ @DisplayName("URL/URI Conversion Tests")
+ class UrlUriConversionTests {
+
+ @Test
+ @DisplayName("getUrl should return URL representation of URI")
+ void getUrlShouldReturnUrlRepresentationOfUri() {
+ URI uri = URI.create(VALID_URL);
+ HttpHandler handler = HttpHandler.builder()
+ .uri(uri)
+ .build();
+
+ URL url = handler.getUrl();
+ assertNotNull(url);
+ assertEquals(VALID_URL, url.toString());
+ }
+
+ @Test
+ @DisplayName("build should throw IllegalStateException for invalid URI")
+ void buildShouldThrowIllegalStateExceptionForInvalidUri() throws Exception {
+ // Create a URI that can't be converted to a valid URL
+ URI invalidUri = new URI("urn:isbn:0451450523"); // URN that can't be converted to URL
+ HttpHandler.HttpHandlerBuilder builder = HttpHandler.builder()
+ .uri(invalidUri);
+ // The build method should throw an IllegalStateException when an invalid URI is provided
+ IllegalStateException exception = assertThrows(IllegalStateException.class, builder::build);
+
+ assertTrue(exception.getMessage().startsWith("Failed to convert URI to URL: urn:isbn:0451450523"));
+ }
+ }
+
+ @Nested
+ @DisplayName("asBuilder Tests")
+ class AsBuilderTests {
+
+ @Test
+ @DisplayName("asBuilder should return non-null builder")
+ void asBuilderShouldReturnNonNullBuilder() {
+ HttpHandler handler = HttpHandler.builder()
+ .url(VALID_URL)
+ .build();
+
+ HttpHandler.HttpHandlerBuilder builder = handler.asBuilder();
+ assertNotNull(builder, "asBuilder should return a non-null builder");
+ }
+
+ @Test
+ @DisplayName("asBuilder should preserve timeout")
+ void asBuilderShouldPreserveTimeout() {
+ HttpHandler handler = HttpHandler.builder()
+ .url(VALID_URL)
+ .requestTimeoutSeconds(CUSTOM_TIMEOUT)
+ .build();
+
+ HttpHandler newHandler = handler.asBuilder()
+ .url(VALID_URL) // Need to set URL again as asBuilder doesn't copy it
+ .build();
+ assertEquals(CUSTOM_TIMEOUT, newHandler.getRequestTimeoutSeconds(),
+ "The new handler should have the same timeout as the original");
+ }
+
+ @Test
+ @DisplayName("asBuilder should preserve SSL context")
+ void asBuilderShouldPreserveSslContext() throws Exception {
+ SSLContext sslContext = SSLContext.getDefault();
+ HttpHandler handler = HttpHandler.builder()
+ .url(VALID_URL)
+ .sslContext(sslContext)
+ .build();
+
+ HttpHandler newHandler = handler.asBuilder()
+ .url(VALID_URL) // Need to set URL again as asBuilder doesn't copy it
+ .build();
+ assertNotNull(newHandler.getSslContext(), "The new handler should have an SSL context");
+ // Note: We can't directly compare SSL contexts as they might be wrapped
+ }
+
+ @Test
+ @DisplayName("asBuilder should allow changing URL")
+ void asBuilderShouldAllowChangingUrl() {
+ HttpHandler handler = HttpHandler.builder()
+ .url(VALID_URL)
+ .requestTimeoutSeconds(CUSTOM_TIMEOUT)
+ .build();
+
+ String newUrl = "https://example.org";
+ HttpHandler newHandler = handler.asBuilder()
+ .url(newUrl)
+ .build();
+
+ assertEquals(URI.create(newUrl), newHandler.getUri(),
+ "The new handler should have the updated URI");
+ assertEquals(CUSTOM_TIMEOUT, newHandler.getRequestTimeoutSeconds(),
+ "The new handler should preserve the original timeout");
+ }
+ }
+}
diff --git a/src/test/java/de/cuioss/tools/net/http/HttpStatusFamilyTest.java b/src/test/java/de/cuioss/tools/net/http/HttpStatusFamilyTest.java
new file mode 100644
index 00000000..220bf8cb
--- /dev/null
+++ b/src/test/java/de/cuioss/tools/net/http/HttpStatusFamilyTest.java
@@ -0,0 +1,268 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.tools.net.http;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for {@link HttpStatusFamily}.
+ */
+class HttpStatusFamilyTest {
+
+ @Nested
+ @DisplayName("fromStatusCode Tests")
+ class FromStatusCodeTests {
+
+ @Test
+ @DisplayName("Should return INFORMATIONAL for 1xx codes")
+ void shouldReturnInformationalFor1xxCodes() {
+ assertEquals(HttpStatusFamily.INFORMATIONAL, HttpStatusFamily.fromStatusCode(100));
+ assertEquals(HttpStatusFamily.INFORMATIONAL, HttpStatusFamily.fromStatusCode(101));
+ assertEquals(HttpStatusFamily.INFORMATIONAL, HttpStatusFamily.fromStatusCode(199));
+ }
+
+ @Test
+ @DisplayName("Should return SUCCESS for 2xx codes")
+ void shouldReturnSuccessFor2xxCodes() {
+ assertEquals(HttpStatusFamily.SUCCESS, HttpStatusFamily.fromStatusCode(200));
+ assertEquals(HttpStatusFamily.SUCCESS, HttpStatusFamily.fromStatusCode(201));
+ assertEquals(HttpStatusFamily.SUCCESS, HttpStatusFamily.fromStatusCode(204));
+ assertEquals(HttpStatusFamily.SUCCESS, HttpStatusFamily.fromStatusCode(299));
+ }
+
+ @Test
+ @DisplayName("Should return REDIRECTION for 3xx codes")
+ void shouldReturnRedirectionFor3xxCodes() {
+ assertEquals(HttpStatusFamily.REDIRECTION, HttpStatusFamily.fromStatusCode(300));
+ assertEquals(HttpStatusFamily.REDIRECTION, HttpStatusFamily.fromStatusCode(301));
+ assertEquals(HttpStatusFamily.REDIRECTION, HttpStatusFamily.fromStatusCode(302));
+ assertEquals(HttpStatusFamily.REDIRECTION, HttpStatusFamily.fromStatusCode(304));
+ assertEquals(HttpStatusFamily.REDIRECTION, HttpStatusFamily.fromStatusCode(399));
+ }
+
+ @Test
+ @DisplayName("Should return CLIENT_ERROR for 4xx codes")
+ void shouldReturnClientErrorFor4xxCodes() {
+ assertEquals(HttpStatusFamily.CLIENT_ERROR, HttpStatusFamily.fromStatusCode(400));
+ assertEquals(HttpStatusFamily.CLIENT_ERROR, HttpStatusFamily.fromStatusCode(401));
+ assertEquals(HttpStatusFamily.CLIENT_ERROR, HttpStatusFamily.fromStatusCode(404));
+ assertEquals(HttpStatusFamily.CLIENT_ERROR, HttpStatusFamily.fromStatusCode(499));
+ }
+
+ @Test
+ @DisplayName("Should return SERVER_ERROR for 5xx codes")
+ void shouldReturnServerErrorFor5xxCodes() {
+ assertEquals(HttpStatusFamily.SERVER_ERROR, HttpStatusFamily.fromStatusCode(500));
+ assertEquals(HttpStatusFamily.SERVER_ERROR, HttpStatusFamily.fromStatusCode(501));
+ assertEquals(HttpStatusFamily.SERVER_ERROR, HttpStatusFamily.fromStatusCode(503));
+ assertEquals(HttpStatusFamily.SERVER_ERROR, HttpStatusFamily.fromStatusCode(599));
+ }
+
+ @Test
+ @DisplayName("Should return UNKNOWN for invalid codes")
+ void shouldReturnUnknownForInvalidCodes() {
+ assertEquals(HttpStatusFamily.UNKNOWN, HttpStatusFamily.fromStatusCode(-1));
+ assertEquals(HttpStatusFamily.UNKNOWN, HttpStatusFamily.fromStatusCode(0));
+ assertEquals(HttpStatusFamily.UNKNOWN, HttpStatusFamily.fromStatusCode(99));
+ assertEquals(HttpStatusFamily.UNKNOWN, HttpStatusFamily.fromStatusCode(600));
+ assertEquals(HttpStatusFamily.UNKNOWN, HttpStatusFamily.fromStatusCode(1000));
+ }
+ }
+
+ @Nested
+ @DisplayName("contains Tests")
+ class ContainsTests {
+
+ @Test
+ @DisplayName("INFORMATIONAL should contain 1xx codes")
+ void informationalShouldContain1xxCodes() {
+ assertTrue(HttpStatusFamily.INFORMATIONAL.contains(100));
+ assertTrue(HttpStatusFamily.INFORMATIONAL.contains(101));
+ assertTrue(HttpStatusFamily.INFORMATIONAL.contains(199));
+ assertFalse(HttpStatusFamily.INFORMATIONAL.contains(99));
+ assertFalse(HttpStatusFamily.INFORMATIONAL.contains(200));
+ }
+
+ @Test
+ @DisplayName("SUCCESS should contain 2xx codes")
+ void successShouldContain2xxCodes() {
+ assertTrue(HttpStatusFamily.SUCCESS.contains(200));
+ assertTrue(HttpStatusFamily.SUCCESS.contains(201));
+ assertTrue(HttpStatusFamily.SUCCESS.contains(299));
+ assertFalse(HttpStatusFamily.SUCCESS.contains(199));
+ assertFalse(HttpStatusFamily.SUCCESS.contains(300));
+ }
+
+ @Test
+ @DisplayName("REDIRECTION should contain 3xx codes")
+ void redirectionShouldContain3xxCodes() {
+ assertTrue(HttpStatusFamily.REDIRECTION.contains(300));
+ assertTrue(HttpStatusFamily.REDIRECTION.contains(301));
+ assertTrue(HttpStatusFamily.REDIRECTION.contains(399));
+ assertFalse(HttpStatusFamily.REDIRECTION.contains(299));
+ assertFalse(HttpStatusFamily.REDIRECTION.contains(400));
+ }
+
+ @Test
+ @DisplayName("CLIENT_ERROR should contain 4xx codes")
+ void clientErrorShouldContain4xxCodes() {
+ assertTrue(HttpStatusFamily.CLIENT_ERROR.contains(400));
+ assertTrue(HttpStatusFamily.CLIENT_ERROR.contains(404));
+ assertTrue(HttpStatusFamily.CLIENT_ERROR.contains(499));
+ assertFalse(HttpStatusFamily.CLIENT_ERROR.contains(399));
+ assertFalse(HttpStatusFamily.CLIENT_ERROR.contains(500));
+ }
+
+ @Test
+ @DisplayName("SERVER_ERROR should contain 5xx codes")
+ void serverErrorShouldContain5xxCodes() {
+ assertTrue(HttpStatusFamily.SERVER_ERROR.contains(500));
+ assertTrue(HttpStatusFamily.SERVER_ERROR.contains(503));
+ assertTrue(HttpStatusFamily.SERVER_ERROR.contains(599));
+ assertFalse(HttpStatusFamily.SERVER_ERROR.contains(499));
+ assertFalse(HttpStatusFamily.SERVER_ERROR.contains(600));
+ }
+
+ @Test
+ @DisplayName("UNKNOWN should contain invalid codes")
+ void unknownShouldContainInvalidCodes() {
+ assertTrue(HttpStatusFamily.UNKNOWN.contains(-1));
+ assertTrue(HttpStatusFamily.UNKNOWN.contains(0));
+ assertTrue(HttpStatusFamily.UNKNOWN.contains(99));
+ assertTrue(HttpStatusFamily.UNKNOWN.contains(600));
+ assertFalse(HttpStatusFamily.UNKNOWN.contains(100));
+ assertFalse(HttpStatusFamily.UNKNOWN.contains(599));
+ }
+ }
+
+ @Nested
+ @DisplayName("Utility Method Tests")
+ class UtilityMethodTests {
+
+ @ParameterizedTest
+ @ValueSource(ints = {200, 201, 204, 299})
+ @DisplayName("isSuccess should return true for 2xx codes")
+ void isSuccessShouldReturnTrueFor2xxCodes(int statusCode) {
+ assertTrue(HttpStatusFamily.isSuccess(statusCode));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {100, 199, 300, 400, 500})
+ @DisplayName("isSuccess should return false for non-2xx codes")
+ void isSuccessShouldReturnFalseForNon2xxCodes(int statusCode) {
+ assertFalse(HttpStatusFamily.isSuccess(statusCode));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {400, 401, 404, 499})
+ @DisplayName("isClientError should return true for 4xx codes")
+ void isClientErrorShouldReturnTrueFor4xxCodes(int statusCode) {
+ assertTrue(HttpStatusFamily.isClientError(statusCode));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {100, 200, 300, 500})
+ @DisplayName("isClientError should return false for non-4xx codes")
+ void isClientErrorShouldReturnFalseForNon4xxCodes(int statusCode) {
+ assertFalse(HttpStatusFamily.isClientError(statusCode));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {500, 501, 503, 599})
+ @DisplayName("isServerError should return true for 5xx codes")
+ void isServerErrorShouldReturnTrueFor5xxCodes(int statusCode) {
+ assertTrue(HttpStatusFamily.isServerError(statusCode));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {100, 200, 300, 400})
+ @DisplayName("isServerError should return false for non-5xx codes")
+ void isServerErrorShouldReturnFalseForNon5xxCodes(int statusCode) {
+ assertFalse(HttpStatusFamily.isServerError(statusCode));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {300, 301, 302, 304, 399})
+ @DisplayName("isRedirection should return true for 3xx codes")
+ void isRedirectionShouldReturnTrueFor3xxCodes(int statusCode) {
+ assertTrue(HttpStatusFamily.isRedirection(statusCode));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {100, 200, 400, 500})
+ @DisplayName("isRedirection should return false for non-3xx codes")
+ void isRedirectionShouldReturnFalseForNon3xxCodes(int statusCode) {
+ assertFalse(HttpStatusFamily.isRedirection(statusCode));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {100, 101, 199})
+ @DisplayName("isInformational should return true for 1xx codes")
+ void isInformationalShouldReturnTrueFor1xxCodes(int statusCode) {
+ assertTrue(HttpStatusFamily.isInformational(statusCode));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {99, 200, 300, 400, 500})
+ @DisplayName("isInformational should return false for non-1xx codes")
+ void isInformationalShouldReturnFalseForNon1xxCodes(int statusCode) {
+ assertFalse(HttpStatusFamily.isInformational(statusCode));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {100, 200, 300, 400, 500, 599})
+ @DisplayName("isValid should return true for valid codes")
+ void isValidShouldReturnTrueForValidCodes(int statusCode) {
+ assertTrue(HttpStatusFamily.isValid(statusCode));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {-1, 0, 99, 600, 1000})
+ @DisplayName("isValid should return false for invalid codes")
+ void isValidShouldReturnFalseForInvalidCodes(int statusCode) {
+ assertFalse(HttpStatusFamily.isValid(statusCode));
+ }
+ }
+
+ @Nested
+ @DisplayName("toString Tests")
+ class ToStringTests {
+
+ @Test
+ @DisplayName("toString should format correctly for standard families")
+ void toStringShouldFormatCorrectlyForStandardFamilies() {
+ assertEquals("Informational (100-199)", HttpStatusFamily.INFORMATIONAL.toString());
+ assertEquals("Success (200-299)", HttpStatusFamily.SUCCESS.toString());
+ assertEquals("Redirection (300-399)", HttpStatusFamily.REDIRECTION.toString());
+ assertEquals("Client Error (400-499)", HttpStatusFamily.CLIENT_ERROR.toString());
+ assertEquals("Server Error (500-599)", HttpStatusFamily.SERVER_ERROR.toString());
+ }
+
+ @Test
+ @DisplayName("toString should format correctly for UNKNOWN")
+ void toStringShouldFormatCorrectlyForUnknown() {
+ assertEquals("Unknown", HttpStatusFamily.UNKNOWN.toString());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/de/cuioss/tools/net/http/SecureSSLContextProviderTest.java b/src/test/java/de/cuioss/tools/net/http/SecureSSLContextProviderTest.java
new file mode 100644
index 00000000..1d936054
--- /dev/null
+++ b/src/test/java/de/cuioss/tools/net/http/SecureSSLContextProviderTest.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package de.cuioss.tools.net.http;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import javax.net.ssl.SSLContext;
+import java.security.KeyManagementException;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for {@link SecureSSLContextProvider} class.
+ */
+@DisplayName("Tests SecureSSLContextProvider functionality")
+class SecureSSLContextProviderTest {
+
+ @Test
+ @DisplayName("Should define correct TLS version constants")
+ void shouldDefineCorrectConstants() {
+ assertEquals("TLSv1.2", SecureSSLContextProvider.TLS_V1_2);
+ assertEquals("TLSv1.3", SecureSSLContextProvider.TLS_V1_3);
+ assertEquals("TLS", SecureSSLContextProvider.TLS);
+ assertEquals("TLSv1.0", SecureSSLContextProvider.TLS_V1_0);
+ assertEquals("TLSv1.1", SecureSSLContextProvider.TLS_V1_1);
+ assertEquals("SSLv3", SecureSSLContextProvider.SSL_V3);
+ assertEquals(SecureSSLContextProvider.TLS_V1_2, SecureSSLContextProvider.DEFAULT_TLS_VERSION);
+ }
+
+ @Test
+ @DisplayName("Should have correct allowed TLS versions")
+ void shouldHaveCorrectAllowedVersions() {
+ assertEquals(3, SecureSSLContextProvider.ALLOWED_TLS_VERSIONS.size());
+ assertTrue(SecureSSLContextProvider.ALLOWED_TLS_VERSIONS.contains(SecureSSLContextProvider.TLS_V1_2));
+ assertTrue(SecureSSLContextProvider.ALLOWED_TLS_VERSIONS.contains(SecureSSLContextProvider.TLS_V1_3));
+ assertTrue(SecureSSLContextProvider.ALLOWED_TLS_VERSIONS.contains(SecureSSLContextProvider.TLS));
+ }
+
+ @Test
+ @DisplayName("Should have correct forbidden TLS versions")
+ void shouldHaveCorrectForbiddenVersions() {
+ assertEquals(3, SecureSSLContextProvider.FORBIDDEN_TLS_VERSIONS.size());
+ assertTrue(SecureSSLContextProvider.FORBIDDEN_TLS_VERSIONS.contains(SecureSSLContextProvider.TLS_V1_0));
+ assertTrue(SecureSSLContextProvider.FORBIDDEN_TLS_VERSIONS.contains(SecureSSLContextProvider.TLS_V1_1));
+ assertTrue(SecureSSLContextProvider.FORBIDDEN_TLS_VERSIONS.contains(SecureSSLContextProvider.SSL_V3));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"TLSv1.2", "TLSv1.3", "TLS"})
+ @DisplayName("Should identify secure TLS versions with default minimum (TLS 1.2)")
+ void shouldIdentifySecureTlsVersionsWithDefaultMinimum(String protocol) {
+ SecureSSLContextProvider secureSSLContextProvider = new SecureSSLContextProvider();
+ assertTrue(secureSSLContextProvider.isSecureTlsVersion(protocol));
+ assertEquals(SecureSSLContextProvider.TLS_V1_2, secureSSLContextProvider.getMinimumTlsVersion());
+ }
+
+ @Test
+ @DisplayName("Should identify secure TLS versions with TLS 1.3 as minimum")
+ void shouldIdentifySecureTlsVersionsWithTls13Minimum() {
+ SecureSSLContextProvider secureSSLContextProvider = new SecureSSLContextProvider(SecureSSLContextProvider.TLS_V1_3);
+
+ // TLS 1.3 and generic TLS should be secure
+ assertTrue(secureSSLContextProvider.isSecureTlsVersion(SecureSSLContextProvider.TLS_V1_3));
+ assertTrue(secureSSLContextProvider.isSecureTlsVersion(SecureSSLContextProvider.TLS));
+
+ // TLS 1.2 should not be secure when minimum is TLS 1.3
+ assertFalse(secureSSLContextProvider.isSecureTlsVersion(SecureSSLContextProvider.TLS_V1_2));
+
+ assertEquals(SecureSSLContextProvider.TLS_V1_3, secureSSLContextProvider.getMinimumTlsVersion());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"TLSv1.0", "TLSv1.1", "SSLv3", "SSLv2", "unknown"})
+ @DisplayName("Should identify insecure TLS versions")
+ void shouldIdentifyInsecureTlsVersions(String protocol) {
+ SecureSSLContextProvider secureSSLContextProvider = new SecureSSLContextProvider();
+ assertFalse(secureSSLContextProvider.isSecureTlsVersion(protocol));
+ }
+
+ @Test
+ @DisplayName("Should handle null protocol")
+ void shouldHandleNullProtocol() {
+ SecureSSLContextProvider secureSSLContextProvider = new SecureSSLContextProvider();
+ assertFalse(secureSSLContextProvider.isSecureTlsVersion(null));
+ }
+
+ @Test
+ @DisplayName("Should have no overlap between allowed and forbidden versions")
+ void shouldHaveNoOverlapBetweenAllowedAndForbidden() {
+ for (String allowed : SecureSSLContextProvider.ALLOWED_TLS_VERSIONS) {
+ assertFalse(SecureSSLContextProvider.FORBIDDEN_TLS_VERSIONS.contains(allowed),
+ "Protocol " + allowed + " should not be in both allowed and forbidden sets");
+ }
+
+ for (String forbidden : SecureSSLContextProvider.FORBIDDEN_TLS_VERSIONS) {
+ assertFalse(SecureSSLContextProvider.ALLOWED_TLS_VERSIONS.contains(forbidden),
+ "Protocol " + forbidden + " should not be in both allowed and forbidden sets");
+ }
+ }
+
+ @Test
+ @DisplayName("Should create secure SSL context with default minimum")
+ void shouldCreateSecureSSLContextWithDefaultMinimum() throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
+ // When: Creating a secure SSL context with default minimum
+ SecureSSLContextProvider secureSSLContextProvider = new SecureSSLContextProvider();
+ SSLContext sslContext = secureSSLContextProvider.createSecureSSLContext();
+
+ // Then: The context should not be null
+ assertNotNull(sslContext, "SSL context should not be null");
+
+ // And: The protocol should be the default TLS version
+ assertEquals(SecureSSLContextProvider.TLS_V1_2, sslContext.getProtocol(),
+ "SSL context should use the default TLS version");
+ }
+
+ @Test
+ @DisplayName("Should create secure SSL context with TLS 1.3 minimum")
+ void shouldCreateSecureSSLContextWithTls13Minimum() throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
+ // When: Creating a secure SSL context with TLS 1.3 minimum
+ SecureSSLContextProvider secureSSLContextProvider = new SecureSSLContextProvider(SecureSSLContextProvider.TLS_V1_3);
+ SSLContext sslContext = secureSSLContextProvider.createSecureSSLContext();
+
+ // Then: The context should not be null
+ assertNotNull(sslContext, "SSL context should not be null");
+
+ // And: The protocol should be TLS 1.3
+ assertEquals(SecureSSLContextProvider.TLS_V1_3, sslContext.getProtocol(),
+ "SSL context should use TLS 1.3");
+ }
+
+ @Test
+ @DisplayName("Should throw exception for invalid minimum TLS version")
+ void shouldThrowExceptionForInvalidMinimumTlsVersion() {
+ assertThrows(IllegalArgumentException.class, () -> new SecureSSLContextProvider("invalid"));
+ }
+
+ @Test
+ @DisplayName("Should validate and return secure SSLContext")
+ void shouldValidateAndReturnSecureSSLContext() throws Exception {
+ // Given: A SecureSSLContextProvider instance and a secure SSLContext
+ SecureSSLContextProvider secureSSLContextProvider = new SecureSSLContextProvider();
+ SSLContext secureContext = SSLContext.getInstance(SecureSSLContextProvider.TLS_V1_2);
+ secureContext.init(null, null, null);
+
+ // When: Validating the secure context
+ SSLContext result = secureSSLContextProvider.getOrCreateSecureSSLContext(secureContext);
+
+ // Then: The same context should be returned
+ assertSame(secureContext, result, "Should return the same context when it's secure");
+ }
+
+ @Test
+ @DisplayName("Should create new SSLContext when provided one is insecure")
+ void shouldCreateNewSSLContextWhenProvidedOneIsInsecure() throws Exception {
+ // Given: A SecureSSLContextProvider instance with TLS 1.3 as minimum and a TLS 1.2 context
+ SecureSSLContextProvider secureSSLContextProvider = new SecureSSLContextProvider(SecureSSLContextProvider.TLS_V1_3);
+ SSLContext insecureContext = SSLContext.getInstance(SecureSSLContextProvider.TLS_V1_2);
+ insecureContext.init(null, null, null);
+
+ // When: Validating the insecure context
+ SSLContext result = secureSSLContextProvider.getOrCreateSecureSSLContext(insecureContext);
+
+ // Then: A new context should be created
+ assertNotSame(insecureContext, result, "Should create a new context when the provided one is insecure");
+ assertEquals(SecureSSLContextProvider.TLS_V1_3, result.getProtocol(), "New context should use TLS 1.3");
+ }
+
+ @Test
+ @DisplayName("Should create new SSLContext when null is provided")
+ void shouldCreateNewSSLContextWhenNullIsProvided() {
+ // Given: A SecureSSLContextProvider instance
+ SecureSSLContextProvider secureSSLContextProvider = new SecureSSLContextProvider();
+
+ // When: Validating a null context
+ SSLContext result = secureSSLContextProvider.getOrCreateSecureSSLContext(null);
+
+ // Then: A new context should be created
+ assertNotNull(result, "Should create a new context when null is provided");
+ assertEquals(SecureSSLContextProvider.TLS_V1_2, result.getProtocol(), "New context should use TLS 1.2");
+ }
+}
diff --git a/src/test/java/de/cuioss/tools/net/ssl/KeyMaterialHolderTest.java b/src/test/java/de/cuioss/tools/net/ssl/KeyMaterialHolderTest.java
index 903d5f94..0e592348 100644
--- a/src/test/java/de/cuioss/tools/net/ssl/KeyMaterialHolderTest.java
+++ b/src/test/java/de/cuioss/tools/net/ssl/KeyMaterialHolderTest.java
@@ -29,10 +29,8 @@ class KeyMaterialHolderTest {
@Test
void shouldBuildWithKeyMaterialOnly() {
- assertNotNull(withRandomKeyMaterial());
-
var builder = KeyMaterialHolder.builder();
- assertThrows(NullPointerException.class, builder::build);
+ assertThrows(NullPointerException.class, builder::build, "expected at least keyMaterial");
}
@Test
+ *
+ *
+ *
+ *
+ * @param sslContext the SSLContext to validate, may be null
+ * @return a secure SSLContext, either the validated input or a newly created one
+ */
+ public SSLContext getOrCreateSecureSSLContext(SSLContext sslContext) {
+ try {
+ if (sslContext != null) {
+ // Validate the provided SSL context
+ String protocol = sslContext.getProtocol();
+ LOGGER.debug(DEBUG_SSL_CONTEXT_PROTOCOL, protocol);
+
+ // Check if the protocol is secure according to the configured TLS versions
+ if (isSecureTlsVersion(protocol)) {
+ // The provided context was secure and is being used
+ LOGGER.debug(DEBUG_USING_SSL_CONTEXT, protocol);
+ return sslContext;
+ }
+
+ // If not secure, create a new secure context
+ LOGGER.warn(WARN_INSECURE_SSL_PROTOCOL, protocol);
+ SSLContext secureContext = createSecureSSLContext();
+ LOGGER.debug(DEBUG_CREATED_SECURE_CONTEXT, minimumTlsVersion);
+ return secureContext;
+ } else {
+ // If no context provided, create a new secure one
+ SSLContext secureContext = createSecureSSLContext();
+ LOGGER.debug(DEBUG_NO_SSL_CONTEXT, minimumTlsVersion);
+ return secureContext;
+ }
+ } catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) {
+ // If we can't create a secure context, use the provided context or try to get the default
+ try {
+ return sslContext != null ? sslContext : SSLContext.getDefault();
+ } catch (Exception ex) {
+ // This should never happen, but just in case
+ throw new IllegalStateException("Failed to create SSL context", ex);
+ }
+ }
+ }
+
+ private static final String DEBUG_SSL_CONTEXT_PROTOCOL = "Provided SSL context uses protocol: %s";
+ private static final String DEBUG_USING_SSL_CONTEXT = "Using provided SSL context with protocol: %s";
+ private static final String WARN_INSECURE_SSL_PROTOCOL = "Provided SSL context uses insecure protocol: %s. Creating a secure context instead.";
+ private static final String DEBUG_CREATED_SECURE_CONTEXT = "Created secure SSL context with %s";
+ private static final String DEBUG_NO_SSL_CONTEXT = "No SSL context provided, created secure SSL context with %s";
+}
diff --git a/src/main/java/de/cuioss/tools/net/http/package-info.java b/src/main/java/de/cuioss/tools/net/http/package-info.java
new file mode 100644
index 00000000..a4d7d9a9
--- /dev/null
+++ b/src/main/java/de/cuioss/tools/net/http/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * Provides HTTP-related utilities and helpers, such as status family detection and secure SSL context providers.
+ *
+ *
+ *