From b1a669ff233f443e67e0719a119442b9bded1994 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 2 Jun 2025 11:53:36 +0200 Subject: [PATCH 1/6] Update github actions --- .github/workflows/maven-release.yml | 2 +- .github/workflows/maven.yml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) 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 }} From f1d743eb6a88aa356c608209d90883641684f89f Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 2 Jun 2025 12:48:58 +0200 Subject: [PATCH 2/6] Update cui-java-parent version to 1.0.2 and change jakarta.annotation-api scope to provided --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 2747ca5a..ae155c5e 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ de.cuioss cui-java-parent - 0.9.9.6 + 1.0.2 @@ -85,7 +85,7 @@ jakarta.annotation jakarta.annotation-api - test + provided \ No newline at end of file From cd776df1041fa50ec7ea1c5361b257c17a8bf693 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 2 Jun 2025 21:10:01 +0200 Subject: [PATCH 3/6] Refactor JoinerConfig and SplitterConfig to use Lombok's @RequiredArgsConstructor and custom builders --- .../de/cuioss/tools/string/JoinerConfig.java | 131 +++++++++------ .../cuioss/tools/string/SplitterConfig.java | 150 +++++++++++------- .../tools/net/ssl/KeyMaterialHolderTest.java | 4 +- 3 files changed, 173 insertions(+), 112 deletions(-) diff --git a/src/main/java/de/cuioss/tools/string/JoinerConfig.java b/src/main/java/de/cuioss/tools/string/JoinerConfig.java index 8707faad..8ff2ba48 100644 --- a/src/main/java/de/cuioss/tools/string/JoinerConfig.java +++ b/src/main/java/de/cuioss/tools/string/JoinerConfig.java @@ -15,9 +15,9 @@ */ package de.cuioss.tools.string; -import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.RequiredArgsConstructor; import lombok.ToString; /** @@ -53,76 +53,105 @@ * @author Oliver Wolff * @see Joiner */ -@Builder @EqualsAndHashCode @ToString -@SuppressWarnings("squid:S1170") -// Sonar doesn't recognize Lombok's @Builder.Default annotations +@RequiredArgsConstructor +@Getter class JoinerConfig { - - /** - * The string used to join elements together. - * This is a required field and must be set in the builder. - */ - @Getter private final String separator; + private final boolean skipNulls; + private final boolean skipEmpty; + private final boolean skipBlank; + private final String useForNull; /** - * Whether to skip null values during joining. - * If true, null values are omitted from the output. - * If false, null values are replaced with {@link #useForNull}. - * Default is false. + * Builder for {@link JoinerConfig}. + * Use the fluent API to configure and call {@link #build()} to create an immutable config. */ - @Builder.Default - @Getter - private final boolean skipNulls = false; + static class Builder { + private String separator; + private boolean skipNulls = false; + private boolean skipEmpty = false; + private boolean skipBlank = false; + private String useForNull = "null"; - /** - * Whether to skip empty strings (length = 0) during joining. - * Takes precedence over {@link #useForNull} if the value is null. - * Default is false. - */ - @Builder.Default - @Getter - private final boolean skipEmpty = false; + /** + * Sets the separator string. + * @param separator the separator to use + * @return this builder + */ + Builder separator(String separator) { + this.separator = separator; + return this; + } - /** - * Whether to skip blank strings (empty or whitespace-only) during joining. - * Takes precedence over both {@link #skipEmpty} and {@link #useForNull}. - * Default is false. - */ - @Builder.Default - @Getter - private final boolean skipBlank = false; + /** + * Whether to skip null values. + * @param skipNulls true to skip nulls + * @return this builder + */ + Builder skipNulls(boolean skipNulls) { + this.skipNulls = skipNulls; + return this; + } - /** - * The string to use in place of null values when {@link #skipNulls} is false. - * Default is "null". - */ - @Builder.Default - @Getter - private final String useForNull = "null"; + /** + * Whether to skip empty strings. + * @param skipEmpty true to skip empty strings + * @return this builder + */ + Builder skipEmpty(boolean skipEmpty) { + this.skipEmpty = skipEmpty; + return this; + } + + /** + * Whether to skip blank strings (whitespace only). + * @param skipBlank true to skip blank strings + * @return this builder + */ + Builder skipBlank(boolean skipBlank) { + this.skipBlank = skipBlank; + return this; + } + + /** + * Sets the string to use for null values. + * @param useForNull replacement for nulls + * @return this builder + */ + Builder useForNull(String useForNull) { + this.useForNull = useForNull; + return this; + } + + /** + * Builds the immutable {@link JoinerConfig} instance. + * @return a new config + */ + JoinerConfig build() { + return new JoinerConfig(separator, skipNulls, skipEmpty, skipBlank, useForNull); + } + } /** - * Empty class required for proper JavaDoc generation with Lombok's @Builder. - * See StackOverflow discussion. + * Creates a new builder for {@link JoinerConfig}. + * @return a new builder */ - @SuppressWarnings("java:S2094") // Empty class required for JavaDoc - public static class JoinerConfigBuilder { + static Builder builder() { + return new Builder(); } /** - * Creates a new builder instance with all current configuration values. - * Useful for creating modified copies of an existing configuration. - * - * @return a new builder initialized with this configuration's values + * Creates a builder pre-populated with this config's values. + * @return a builder with copied values */ - JoinerConfigBuilder copy() { + Builder copy() { return builder() .separator(getSeparator()) - .useForNull(getUseForNull()) + .skipNulls(isSkipNulls()) .skipEmpty(isSkipEmpty()) .skipBlank(isSkipBlank()) - .skipNulls(isSkipNulls()); + .useForNull(getUseForNull()); } } diff --git a/src/main/java/de/cuioss/tools/string/SplitterConfig.java b/src/main/java/de/cuioss/tools/string/SplitterConfig.java index 8024bb44..ca8697ea 100644 --- a/src/main/java/de/cuioss/tools/string/SplitterConfig.java +++ b/src/main/java/de/cuioss/tools/string/SplitterConfig.java @@ -15,9 +15,9 @@ */ package de.cuioss.tools.string; -import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.RequiredArgsConstructor; import lombok.ToString; import java.util.regex.Pattern; @@ -62,84 +62,118 @@ * @see Splitter * @see Pattern */ -@Builder +@RequiredArgsConstructor(staticName = "of") +@Getter @EqualsAndHashCode @ToString -@SuppressWarnings("squid:S1170") -// Sonar doesn't recognize Lombok's @Builder.Default annotations class SplitterConfig { - - /** - * The string or pattern used to split input strings. - * When {@link #doNotModifySeparatorString} is false (default), special regex characters - * will be automatically escaped. - * This is a required field and must be set in the builder. - */ - @Getter private final String separator; - - /** - * The compiled pattern used to split input strings. - */ - @Getter private final Pattern pattern; + private final boolean omitEmptyStrings; + private final boolean trimResults; + private final int maxItems; + private final boolean doNotModifySeparatorString; /** - * Whether to exclude empty strings from the split results. - * An empty string is defined as a string of length zero. - * Default is false. + * Builder for {@link SplitterConfig}. + * Use the fluent API to configure and call {@link #build()} to create an immutable config. */ - @Builder.Default - @Getter - private final boolean omitEmptyStrings = false; + static class Builder { + private String separator; + private boolean omitEmptyStrings = false; + private boolean trimResults = false; + private int maxItems = 0; + private boolean doNotModifySeparatorString = false; + private Pattern pattern; - /** - * Whether to trim whitespace from the beginning and end of each split result. - * Trimming is performed using {@link String#trim()}. - * Default is false. - */ - @Builder.Default - @Getter - private final boolean trimResults = false; + /** + * Sets the separator string. + * @param separator the separator to use + * @return this builder + */ + Builder separator(String separator) { + this.separator = separator; + return this; + } - /** - * Maximum number of splits to perform. - * After this limit is reached, the remainder of the input string becomes the last element. - * A value of 0 (default) means no limit. - */ - @Builder.Default - @Getter - private final int maxItems = 0; + /** + * Whether to omit empty strings from the result. + * @param omitEmptyStrings true to omit empty strings + * @return this builder + */ + Builder omitEmptyStrings(boolean omitEmptyStrings) { + this.omitEmptyStrings = omitEmptyStrings; + return this; + } - /** - * Whether to use the separator string as-is without escaping special regex characters. - * When true, the separator is used directly as a regex pattern. - * When false (default), special regex characters are escaped. - */ - @Builder.Default - @Getter - private final boolean doNotModifySeparatorString = false; + /** + * Whether to trim results. + * @param trimResults true to trim results + * @return this builder + */ + Builder trimResults(boolean trimResults) { + this.trimResults = trimResults; + return this; + } + + /** + * Sets the maximum number of items to split. + * @param maxItems the split limit + * @return this builder + */ + Builder maxItems(int maxItems) { + this.maxItems = maxItems; + return this; + } + + /** + * Whether to use the separator string as-is. + * @param doNotModifySeparatorString true to use as-is + * @return this builder + */ + Builder doNotModifySeparatorString(boolean doNotModifySeparatorString) { + this.doNotModifySeparatorString = doNotModifySeparatorString; + return this; + } + + /** + * Sets the pattern to use for splitting. + * @param pattern the regex pattern + * @return this builder + */ + Builder pattern(Pattern pattern) { + this.pattern = pattern; + return this; + } + + /** + * Builds the immutable {@link SplitterConfig} instance. + * @return a new config + */ + SplitterConfig build() { + return SplitterConfig.of(separator, pattern, omitEmptyStrings, trimResults, maxItems, doNotModifySeparatorString); + } + } /** - * Empty class required for proper JavaDoc generation with Lombok's @Builder. - * See StackOverflow discussion. + * Creates a new builder for {@link SplitterConfig}. + * @return a new builder */ - @SuppressWarnings("java:S2094") // Empty class required for JavaDoc - public static class SplitterConfigBuilder { + static Builder builder() { + return new Builder(); } /** - * Creates a new builder instance with all current configuration values. - * Useful for creating modified copies of an existing configuration. - * - * @return a new builder initialized with this configuration's values + * Creates a builder pre-populated with this config's values. + * @return a builder with copied values */ - SplitterConfigBuilder copy() { + Builder copy() { return builder() .separator(getSeparator()) + .omitEmptyStrings(isOmitEmptyStrings()) + .trimResults(isTrimResults()) .maxItems(getMaxItems()) .doNotModifySeparatorString(isDoNotModifySeparatorString()) - .omitEmptyStrings(isOmitEmptyStrings()) - .trimResults(isTrimResults()); + .pattern(getPattern()); } } 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..2102aa1a 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, "KeyMaterial must not be null"); } @Test From 71769c37f0034965d0781d2296e9ad7eaa1cb1f5 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 2 Jun 2025 21:24:41 +0200 Subject: [PATCH 4/6] Adding de.cuioss.tools.net.http --- .../de/cuioss/tools/net/http/HttpHandler.java | 402 +++++++++++++++++ .../tools/net/http/HttpStatusFamily.java | 173 ++++++++ .../net/http/SecureSSLContextProvider.java | 242 ++++++++++ src/main/java/module-info.java | 2 + .../tools/net/http/HttpHandlerTest.java | 417 ++++++++++++++++++ .../tools/net/http/HttpStatusFamilyTest.java | 268 +++++++++++ .../http/SecureSSLContextProviderTest.java | 205 +++++++++ 7 files changed, 1709 insertions(+) create mode 100644 src/main/java/de/cuioss/tools/net/http/HttpHandler.java create mode 100644 src/main/java/de/cuioss/tools/net/http/HttpStatusFamily.java create mode 100644 src/main/java/de/cuioss/tools/net/http/SecureSSLContextProvider.java create mode 100644 src/test/java/de/cuioss/tools/net/http/HttpHandlerTest.java create mode 100644 src/test/java/de/cuioss/tools/net/http/HttpStatusFamilyTest.java create mode 100644 src/test/java/de/cuioss/tools/net/http/SecureSSLContextProviderTest.java diff --git a/src/main/java/de/cuioss/tools/net/http/HttpHandler.java b/src/main/java/de/cuioss/tools/net/http/HttpHandler.java new file mode 100644 index 00000000..cf45e6e1 --- /dev/null +++ b/src/main/java/de/cuioss/tools/net/http/HttpHandler.java @@ -0,0 +1,402 @@ +/* + * 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 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. + * This class is designed to be easily extractable to a separate module in the future. + * It provides a consistent way to configure and execute HTTP requests with proper + * SSL context handling and timeout configuration. + * Contract: + *

+ * + * Use the builder to create instances of this class: + *
+ * 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 response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); + return HttpStatusFamily.fromStatusCode(response.statusCode()); + } catch (IOException e) { + LOGGER.warn(e, "IO error while pinging URI %s: %s", uri, e.getMessage()); + return HttpStatusFamily.UNKNOWN; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOGGER.warn("Interrupted while pinging URI %s: %s", uri, e.getMessage()); + return HttpStatusFamily.UNKNOWN; + } catch (Exception e) { + LOGGER.warn(e, "Error while pinging URI %s: %s", uri, e.getMessage()); + return HttpStatusFamily.UNKNOWN; + } + } + + /** + * Creates an {@link HttpClient} with the configured SSL context and timeout. + * This method can be used to get a pre-configured HttpClient for making HTTP requests. + * + * @return A configured {@link HttpClient} with the SSL context and timeout + * @throws IllegalStateException if the URI uses HTTPS but no SSL context is available + */ + public HttpClient createHttpClient() { + HttpClient.Builder httpClientBuilder = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(requestTimeoutSeconds)); + + // For HTTPS URIs, SSL context must be set and not null + if ("https".equalsIgnoreCase(uri.getScheme())) { + if (sslContext == null) { + throw new IllegalStateException("SSL context is required for HTTPS URI: " + uri); + } + httpClientBuilder.sslContext(sslContext); + } else if (sslContext != null) { + // For non-HTTPS URIs, still use the SSL context if provided + httpClientBuilder.sslContext(sslContext); + } + + return httpClientBuilder.build(); + } + + /** + * Builder for creating {@link HttpHandler} instances. + */ + public static class HttpHandlerBuilder { + private URI uri; + private URL url; + private String urlString; + private SSLContext sslContext; + private SecureSSLContextProvider secureSSLContextProvider; + private Integer requestTimeoutSeconds; + + /** + * Sets the URI as a string. + * + * @param uriString The string representation of the URI. + * Must not be null or empty. + * @return This builder instance. + * @throws IllegalArgumentException if the URI string is null, empty, or malformed + * (thrown during the {@link #build()} method execution, + * not by this setter method) + */ + public HttpHandlerBuilder uri(String uriString) { + this.urlString = uriString; + return this; + } + + /** + * Sets the URI directly. + *

+ * 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 ALLOWED_TLS_VERSIONS = CollectionLiterals.immutableSet(TLS_V1_2, TLS_V1_3, TLS); + + /** + * Set of forbidden (insecure) TLS versions + */ + public static final Set FORBIDDEN_TLS_VERSIONS = CollectionLiterals.immutableSet(TLS_V1_0, TLS_V1_1, SSL_V3); + + /** + * The minimum TLS version that is considered secure for this instance. + */ + @Getter + private final String minimumTlsVersion; + + /** + * Creates a new SecureSSLContextProvider instance with the default minimum TLS version (TLS 1.2). + */ + public SecureSSLContextProvider() { + this(DEFAULT_TLS_VERSION); + } + + /** + * Creates a new SecureSSLContextProvider instance with the specified minimum TLS version. + * + * @param minimumTlsVersion the minimum TLS version to consider secure + * @throws IllegalArgumentException if the specified version is not in the allowed set + */ + public SecureSSLContextProvider(String minimumTlsVersion) { + if (!ALLOWED_TLS_VERSIONS.contains(minimumTlsVersion)) { + throw new IllegalArgumentException("Minimum TLS version must be one of the allowed versions: " + ALLOWED_TLS_VERSIONS); + } + this.minimumTlsVersion = minimumTlsVersion; + } + + /** + * Checks if the given protocol is a secure TLS version according to the minimum version set for this instance. + *

+ * 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: + *

    + *
  1. Creates an SSLContext instance with the secure protocol version
  2. + *
  3. Initializes a TrustManagerFactory with the default algorithm
  4. + *
  5. Configures the TrustManagerFactory to use the default trust store
  6. + *
  7. Initializes the SSLContext with the trust managers and a secure random source
  8. + *
+ *

+ * 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: + *

    + *
  1. If the provided SSLContext is null, creates a new secure SSLContext
  2. + *
  3. If the provided SSLContext is not null, checks if its protocol is secure
  4. + *
  5. If the protocol is secure, returns the provided SSLContext
  6. + *
  7. If the protocol is not secure, creates a new secure SSLContext
  8. + *
  9. If an exception occurs during validation or creation, falls back to the provided SSLContext or the default SSLContext
  10. + *
+ * + * @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/module-info.java b/src/main/java/module-info.java index 49f6f899..0420ad3c 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -2,6 +2,7 @@ requires static lombok; requires transitive java.desktop; requires transitive java.logging; + requires java.net.http; exports de.cuioss.tools.base; exports de.cuioss.tools.codec; @@ -11,6 +12,7 @@ exports de.cuioss.tools.formatting.template; exports de.cuioss.tools.formatting.template.lexer; exports de.cuioss.tools.formatting.template.token; + exports de.cuioss.tools.net.http; exports de.cuioss.tools.io; exports de.cuioss.tools.lang; exports de.cuioss.tools.logging; diff --git a/src/test/java/de/cuioss/tools/net/http/HttpHandlerTest.java b/src/test/java/de/cuioss/tools/net/http/HttpHandlerTest.java new file mode 100644 index 00000000..7991e75d --- /dev/null +++ b/src/test/java/de/cuioss/tools/net/http/HttpHandlerTest.java @@ -0,0 +1,417 @@ +/* + * 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 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"); + } +} From 0eafad84a36ffd72bf666f37e66793a1de074fec Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 2 Jun 2025 21:31:57 +0200 Subject: [PATCH 5/6] Adding javadoc --- README.adoc | 29 +++++++++++++++++++ .../de/cuioss/tools/net/http/HttpHandler.java | 1 - .../cuioss/tools/net/http/package-info.java | 7 +++++ .../de/cuioss/tools/net/package-info.java | 10 +++++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 src/main/java/de/cuioss/tools/net/http/package-info.java 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/src/main/java/de/cuioss/tools/net/http/HttpHandler.java b/src/main/java/de/cuioss/tools/net/http/HttpHandler.java index cf45e6e1..4a03ed3d 100644 --- a/src/main/java/de/cuioss/tools/net/http/HttpHandler.java +++ b/src/main/java/de/cuioss/tools/net/http/HttpHandler.java @@ -37,7 +37,6 @@ /** * A common wrapper around {@link HttpClient} that provides a builder for collecting * HTTP request attributes and methods for executing HTTP requests. - * This class is designed to be easily extractable to a separate module in the future. * It provides a consistent way to configure and execute HTTP requests with proper * SSL context handling and timeout configuration. * Contract: 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. + *

+ * 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 @@ *

  • {@link de.cuioss.tools.net.ssl.KeyStoreType} - KeyStore types
  • * * + *
  • HTTP Utilities + * + *
  • * * * @author Oliver Wolff @@ -38,5 +45,8 @@ * @see de.cuioss.tools.net.UrlParameter * @see de.cuioss.tools.net.IDNInternetAddress * @see de.cuioss.tools.net.ssl.KeyStoreProvider + * @see de.cuioss.tools.net.http.HttpHandler + * @see de.cuioss.tools.net.http.HttpStatusFamily + * @see de.cuioss.tools.net.http.SecureSSLContextProvider */ package de.cuioss.tools.net; From 480e94bf73f8f56d1231726d7430281dd526224a Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 2 Jun 2025 21:50:06 +0200 Subject: [PATCH 6/6] Refactor HttpHandler to simplify SSL context handling and update test assertion message --- .../java/de/cuioss/tools/net/http/HttpHandler.java | 12 +----------- .../cuioss/tools/net/ssl/KeyMaterialHolderTest.java | 2 +- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/main/java/de/cuioss/tools/net/http/HttpHandler.java b/src/main/java/de/cuioss/tools/net/http/HttpHandler.java index 4a03ed3d..2cf7b55d 100644 --- a/src/main/java/de/cuioss/tools/net/http/HttpHandler.java +++ b/src/main/java/de/cuioss/tools/net/http/HttpHandler.java @@ -45,8 +45,6 @@ * an {@link IllegalStateException} during build. *
  • For HTTPS connections, a valid {@link SSLContext} is required. If not explicitly * provided, one will be automatically created during build.
  • - *
  • If an HTTPS connection is attempted without a valid SSL context, an - * {@link IllegalStateException} will be thrown.
  • * * * Use the builder to create instances of this class: @@ -182,23 +180,15 @@ private HttpStatusFamily pingWithMethod(String method, HttpRequest.BodyPublisher * This method can be used to get a pre-configured HttpClient for making HTTP requests. * * @return A configured {@link HttpClient} with the SSL context and timeout - * @throws IllegalStateException if the URI uses HTTPS but no SSL context is available */ public HttpClient createHttpClient() { HttpClient.Builder httpClientBuilder = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(requestTimeoutSeconds)); - // For HTTPS URIs, SSL context must be set and not null + // For HTTPS URIs, SSL context must be set if ("https".equalsIgnoreCase(uri.getScheme())) { - if (sslContext == null) { - throw new IllegalStateException("SSL context is required for HTTPS URI: " + uri); - } - httpClientBuilder.sslContext(sslContext); - } else if (sslContext != null) { - // For non-HTTPS URIs, still use the SSL context if provided httpClientBuilder.sslContext(sslContext); } - return httpClientBuilder.build(); } 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 2102aa1a..0e592348 100644 --- a/src/test/java/de/cuioss/tools/net/ssl/KeyMaterialHolderTest.java +++ b/src/test/java/de/cuioss/tools/net/ssl/KeyMaterialHolderTest.java @@ -30,7 +30,7 @@ class KeyMaterialHolderTest { @Test void shouldBuildWithKeyMaterialOnly() { var builder = KeyMaterialHolder.builder(); - assertThrows(NullPointerException.class, builder::build, "KeyMaterial must not be null"); + assertThrows(NullPointerException.class, builder::build, "expected at least keyMaterial"); } @Test