From c3c1a541dd119e6fcb1189f8f0e18583ccc31325 Mon Sep 17 00:00:00 2001 From: MudassarTariq Date: Tue, 3 Nov 2026 15:21:37 +0500 Subject: [PATCH] Update dependencies, timeouts, response handling and Arabic support --- .github/workflows/ci.yml | 29 ++++ README.md | 3 +- pom.xml | 141 ++++++++++++++- .../sdk/IpGeolocationClient.java | 13 +- .../java/io/ipgeolocation/sdk/Language.java | 3 +- .../sdk/internal/ErrorSanitizer.java | 29 ++++ .../sdk/internal/JavaNetHttpExecutor.java | 8 +- .../sdk/internal/ResponseMapper.java | 26 ++- .../sdk/internal/UriBuilder.java | 6 +- .../io/ipgeolocation/sdk/model/Network.java | 27 ++- .../io/ipgeolocation/sdk/model/Security.java | 127 +++++++++++++- .../IpGeolocationClientErrorMappingTest.java | 2 +- ...pGeolocationClientRequestBuildingTest.java | 31 ++++ .../sdk/IpGeolocationClientSecurityTest.java | 164 ++++++++++++++++++ .../sdk/IpGeolocationLiveIntegrationTest.java | 38 ++-- .../io/ipgeolocation/sdk/LanguageTest.java | 14 ++ .../io/ipgeolocation/sdk/NetworkTest.java | 32 ++++ .../ipgeolocation/sdk/SecurityFieldsTest.java | 49 ++++++ .../sdk/ValueObjectCoverageTest.java | 5 + .../sdk/internal/JavaNetHttpExecutorTest.java | 55 +++++- 20 files changed, 756 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/main/java/io/ipgeolocation/sdk/internal/ErrorSanitizer.java create mode 100644 src/test/java/io/ipgeolocation/sdk/IpGeolocationClientSecurityTest.java create mode 100644 src/test/java/io/ipgeolocation/sdk/NetworkTest.java create mode 100644 src/test/java/io/ipgeolocation/sdk/SecurityFieldsTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8d070bd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: Java checks + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java: ['8', '21'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad + with: + distribution: temurin + java-version: ${{ matrix.java }} + - run: mvn --batch-mode --no-transfer-progress clean verify + env: + IPGEO_RUN_LIVE_TESTS: 'false' + IPGEO_RUN_LIVE_HARDENING: 'false' diff --git a/README.md b/README.md index 11c615c..994aaec 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ Language values supported by `.lang(...)`: - `Language.KO` for Korean - `Language.FA` for Persian - `Language.PT` for Portuguese +- `Language.AR` for Arabic Dot-separated field paths can be passed to `.fields(...)` and `.excludes(...)` to control which parts of the response are returned. @@ -690,7 +691,7 @@ String full = JsonOutput.toPrettyJson(response.data(), JsonOutputMode.FULL); ## Error Handling -All SDK exceptions extend `IpGeolocationException`. +All SDK exceptions extend `IpGeolocationException`. If an error includes your API key, the SDK replaces it with `[REDACTED]`. The error type and HTTP status stay the same. Some technical error details may be shortened to keep the key hidden. | HTTP Status | Exception | |-------------|-----------| diff --git a/pom.xml b/pom.xml index 6a6a3c9..4fb7394 100644 --- a/pom.xml +++ b/pom.xml @@ -38,15 +38,15 @@ 8 UTF-8 - 2.21.7 + 2.21.8 5.11.4 3.27.7 - 0.8.12 + 0.8.16 0.90 0.90 0.90 - 3.3.1 - 3.8.0 + 3.4.0 + 3.12.0 3.2.7 0.10.0 @@ -99,10 +99,36 @@ + + org.apache.maven.plugins + maven-clean-plugin + 3.5.0 + + + org.codehaus.plexus + plexus-utils + 4.0.3 + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.5.0 + + + org.codehaus.plexus + plexus-utils + 3.6.1 + + + + org.apache.maven.plugins maven-jar-plugin - 3.4.2 + 3.5.1 @@ -115,22 +141,48 @@ org.apache.maven.plugins maven-compiler-plugin - 3.13.0 + 3.16.0 ${maven.compiler.release} + + org.apache.maven.plugins + maven-install-plugin + 3.2.0 + + + + org.apache.maven.plugins + maven-deploy-plugin + 3.2.0 + + org.apache.maven.plugins maven-surefire-plugin - 3.5.2 + 3.5.6 + + + org.codehaus.plexus + plexus-utils + 3.6.1 + + org.jacoco jacoco-maven-plugin ${jacoco.version} + + + org.codehaus.plexus + plexus-utils + 3.6.1 + + prepare-agent @@ -184,7 +236,7 @@ org.apache.maven.plugins maven-enforcer-plugin - 3.5.0 + 3.7.0 enforce-java @@ -218,6 +270,18 @@ org.apache.maven.plugins maven-source-plugin ${maven.source.plugin.version} + + + org.codehaus.plexus + plexus-utils + 4.0.3 + + + io.airlift + aircompressor + 2.0.3 + + attach-sources @@ -232,6 +296,23 @@ org.apache.maven.plugins maven-javadoc-plugin ${maven.javadoc.plugin.version} + + + org.codehaus.plexus + plexus-utils + 4.0.3 + + + io.airlift + aircompressor + 2.0.3 + + + commons-beanutils + commons-beanutils + 1.11.0 + + ${maven.compiler.release} all,-missing @@ -252,6 +333,28 @@ org.apache.maven.plugins maven-gpg-plugin ${maven.gpg.plugin.version} + + + org.codehaus.plexus + plexus-utils + 4.0.3 + + + org.bouncycastle + bcpg-jdk18on + 1.85 + + + org.bouncycastle + bcprov-jdk18on + 1.85 + + + org.bouncycastle + bcutil-jdk18on + 1.85 + + sign-artifacts @@ -268,6 +371,28 @@ central-publishing-maven-plugin ${central.publishing.plugin.version} true + + + org.codehaus.plexus + plexus-utils + 3.6.1 + + + com.google.guava + guava + 33.7.2-jre + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + org.apache.httpcomponents.client5 + httpclient5 + 5.6.3 + + central true diff --git a/src/main/java/io/ipgeolocation/sdk/IpGeolocationClient.java b/src/main/java/io/ipgeolocation/sdk/IpGeolocationClient.java index 10abe65..5d0e032 100644 --- a/src/main/java/io/ipgeolocation/sdk/IpGeolocationClient.java +++ b/src/main/java/io/ipgeolocation/sdk/IpGeolocationClient.java @@ -8,6 +8,7 @@ import io.ipgeolocation.sdk.exceptions.TransportException; import io.ipgeolocation.sdk.exceptions.ValidationException; import io.ipgeolocation.sdk.internal.HttpExecutor; +import io.ipgeolocation.sdk.internal.ErrorSanitizer; import io.ipgeolocation.sdk.internal.HttpRequestData; import io.ipgeolocation.sdk.internal.HttpResponseData; import io.ipgeolocation.sdk.internal.JavaNetHttpExecutor; @@ -46,6 +47,7 @@ public final class IpGeolocationClient implements AutoCloseable { private final ObjectMapper objectMapper; private final HttpExecutor executor; private final ResponseMapper responseMapper; + private final ErrorSanitizer errors; /** * Creates a client that uses the provided SDK configuration. @@ -63,7 +65,8 @@ public IpGeolocationClient(IpGeolocationClientConfig config) { this.config = Objects.requireNonNull(config, "config must not be null"); this.executor = Objects.requireNonNull(executor, "executor must not be null"); this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null"); - this.responseMapper = new ResponseMapper(this.objectMapper); + this.responseMapper = new ResponseMapper(this.objectMapper, config.apiKey()); + this.errors = new ErrorSanitizer(config.apiKey()); } /** * Executes a single-IP lookup request and returns typed data plus response metadata. @@ -249,7 +252,7 @@ private HttpRequestData buildBulkHttpRequest( try { payload = objectMapper.writeValueAsString(new BulkGeolocationRequestBody(request.ips())); } catch (JsonProcessingException e) { - throw new SerializationException("Failed to serialize bulk lookup request body", e); + throw new SerializationException("Failed to serialize bulk lookup request body", errors.cause(e)); } Map> headers = new LinkedHashMap>(); @@ -318,12 +321,12 @@ private HttpResponseData executeOnce(HttpRequestData request, Duration timeout) try { return executor.send(request); } catch (SocketTimeoutException e) { - throw new RequestTimeoutException("HTTP request timed out", e); + throw new RequestTimeoutException("HTTP request timed out", errors.cause(e)); } catch (IOException e) { - throw new TransportException("HTTP transport error", e); + throw new TransportException("HTTP transport error", errors.cause(e)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new TransportException("Request execution was interrupted", e); + throw new TransportException("Request execution was interrupted", errors.cause(e)); } } diff --git a/src/main/java/io/ipgeolocation/sdk/Language.java b/src/main/java/io/ipgeolocation/sdk/Language.java index 375d50f..521affd 100644 --- a/src/main/java/io/ipgeolocation/sdk/Language.java +++ b/src/main/java/io/ipgeolocation/sdk/Language.java @@ -17,7 +17,8 @@ public enum Language { IT("it"), KO("ko"), FA("fa"), - PT("pt"); + PT("pt"), + AR("ar"); private final String code; diff --git a/src/main/java/io/ipgeolocation/sdk/internal/ErrorSanitizer.java b/src/main/java/io/ipgeolocation/sdk/internal/ErrorSanitizer.java new file mode 100644 index 0000000..15946ff --- /dev/null +++ b/src/main/java/io/ipgeolocation/sdk/internal/ErrorSanitizer.java @@ -0,0 +1,29 @@ +package io.ipgeolocation.sdk.internal; + +import java.io.IOException; + +public final class ErrorSanitizer { + private final String apiKey; + private final String encodedApiKey; + + public ErrorSanitizer(String apiKey) { + this.apiKey = apiKey; + this.encodedApiKey = Compat.isBlank(apiKey) ? null : UriBuilder.encode(apiKey, "UTF-8"); + } + + public String message(String value) { + if (value == null || encodedApiKey == null) { + return value; + } + return value.replace(encodedApiKey, "[REDACTED]").replace(apiKey, "[REDACTED]"); + } + + public Throwable cause(Throwable error) { + if (encodedApiKey == null) { + return error; + } + IOException sanitized = new IOException(error.getClass().getName()); + sanitized.setStackTrace(error.getStackTrace()); + return sanitized; + } +} diff --git a/src/main/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutor.java b/src/main/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutor.java index 05eeb6f..5d983c7 100644 --- a/src/main/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutor.java +++ b/src/main/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutor.java @@ -122,11 +122,13 @@ private String readBody(HttpURLConnection connection, int statusCode) throws IOE } private static int toMillis(Duration duration) { - long millis = duration.toMillis(); - if (millis > Integer.MAX_VALUE) { + if (duration.isNegative() || duration.isZero()) { + throw new IllegalArgumentException("timeout must be greater than zero"); + } + if (duration.compareTo(Duration.ofMillis(Integer.MAX_VALUE)) >= 0) { return Integer.MAX_VALUE; } - return (int) millis; + return (int) Math.max(1L, duration.toMillis()); } interface ConnectionFactory { diff --git a/src/main/java/io/ipgeolocation/sdk/internal/ResponseMapper.java b/src/main/java/io/ipgeolocation/sdk/internal/ResponseMapper.java index 4bb90db..785d649 100644 --- a/src/main/java/io/ipgeolocation/sdk/internal/ResponseMapper.java +++ b/src/main/java/io/ipgeolocation/sdk/internal/ResponseMapper.java @@ -28,23 +28,33 @@ public final class ResponseMapper { private final ObjectMapper objectMapper; + private final ErrorSanitizer errors; public ResponseMapper(ObjectMapper objectMapper) { + this(objectMapper, null); + } + + public ResponseMapper(ObjectMapper objectMapper, String apiKey) { this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null"); + this.errors = new ErrorSanitizer(apiKey); } public T readBody(String body, Class type) { try { - return objectMapper.readValue(body, type); + T result = objectMapper.readValue(body, type); + if (result == null) { + throw new IOException("Expected a JSON object"); + } + return result; } catch (IOException e) { - throw new SerializationException("Failed to deserialize API response", e); + throw new SerializationException("Failed to deserialize API response", errors.cause(e)); } } public List parseBulkResponse(String body) { try { JsonNode root = objectMapper.readTree(body); - if (!root.isArray()) { + if (root == null || !root.isArray()) { throw new SerializationException( "Failed to deserialize bulk response: expected an array payload", new IllegalArgumentException("Unexpected JSON token")); @@ -52,6 +62,9 @@ public List parseBulkResponse(String body) { List result = new ArrayList<>(root.size()); for (JsonNode node : root) { + if (!node.isObject()) { + throw new IOException("Expected a JSON object for each bulk result"); + } if (node.has("message") && !node.has("ip")) { ErrorResponse error = objectMapper.treeToValue(node, ErrorResponse.class); result.add(new BulkLookupError(error.message())); @@ -62,7 +75,7 @@ public List parseBulkResponse(String body) { } return result; } catch (IOException e) { - throw new SerializationException("Failed to deserialize bulk lookup response", e); + throw new SerializationException("Failed to deserialize bulk lookup response", errors.cause(e)); } } @@ -145,9 +158,10 @@ private String extractApiMessage(String body) { } try { ErrorResponse err = objectMapper.readValue(body, ErrorResponse.class); - return err.message(); + return err == null ? null : errors.message(err.message()); } catch (IOException ignored) { - return body.length() > 512 ? body.substring(0, 512) : body; + String message = errors.message(body); + return message.length() > 512 ? message.substring(0, 512) : message; } } } diff --git a/src/main/java/io/ipgeolocation/sdk/internal/UriBuilder.java b/src/main/java/io/ipgeolocation/sdk/internal/UriBuilder.java index aeeea42..b2f79f2 100644 --- a/src/main/java/io/ipgeolocation/sdk/internal/UriBuilder.java +++ b/src/main/java/io/ipgeolocation/sdk/internal/UriBuilder.java @@ -36,7 +36,11 @@ public URI build() { queryParams.forEach((k, v) -> joiner.add(encode(k) + "=" + encode(v))); full.append('?').append(joiner); } - return URI.create(full.toString()); + try { + return URI.create(full.toString()); + } catch (IllegalArgumentException invalidUri) { + throw new IllegalArgumentException("Invalid API request URI"); + } } private static String encode(String value) { diff --git a/src/main/java/io/ipgeolocation/sdk/model/Network.java b/src/main/java/io/ipgeolocation/sdk/model/Network.java index e125b06..3047642 100644 --- a/src/main/java/io/ipgeolocation/sdk/model/Network.java +++ b/src/main/java/io/ipgeolocation/sdk/model/Network.java @@ -17,20 +17,33 @@ public final class Network { private final String connectionType; private final String route; private final Boolean isAnycast; + private final Boolean isCdn; + private final String cdnProviderName; + + public Network( + String connectionType, String route, Boolean isAnycast) { + this(connectionType, route, isAnycast, null, null); + } @JsonCreator public Network( @JsonProperty("connection_type") String connectionType, @JsonProperty("route") String route, - @JsonProperty("is_anycast") Boolean isAnycast) { + @JsonProperty("is_anycast") Boolean isAnycast, + @JsonProperty("is_cdn") Boolean isCdn, + @JsonProperty("cdn_provider_name") String cdnProviderName) { this.connectionType = connectionType; this.route = route; this.isAnycast = isAnycast; + this.isCdn = isCdn; + this.cdnProviderName = cdnProviderName; } public String connectionType() { return connectionType; } public String route() { return route; } public Boolean isAnycast() { return isAnycast; } + public Boolean isCdn() { return isCdn; } + public String cdnProviderName() { return cdnProviderName; } @Override public boolean equals(Object other) { @@ -43,12 +56,14 @@ public boolean equals(Object other) { Network that = (Network) other; return Objects.equals(connectionType, that.connectionType) && Objects.equals(route, that.route) - && Objects.equals(isAnycast, that.isAnycast); + && Objects.equals(isAnycast, that.isAnycast) + && Objects.equals(isCdn, that.isCdn) + && Objects.equals(cdnProviderName, that.cdnProviderName); } @Override public int hashCode() { - return Objects.hash(connectionType, route, isAnycast); + return Objects.hash(connectionType, route, isAnycast, isCdn, cdnProviderName); } @Override @@ -59,6 +74,10 @@ public String toString() { + route + "', isAnycast=" + isAnycast - + '}'; + + ", isCdn=" + + isCdn + + ", cdnProviderName='" + + cdnProviderName + + "'}"; } } diff --git a/src/main/java/io/ipgeolocation/sdk/model/Security.java b/src/main/java/io/ipgeolocation/sdk/model/Security.java index 3bddab3..0f12731 100644 --- a/src/main/java/io/ipgeolocation/sdk/model/Security.java +++ b/src/main/java/io/ipgeolocation/sdk/model/Security.java @@ -40,6 +40,47 @@ public final class Security { private final Boolean isCloudProvider; private final String cloudProviderName; + @JsonProperty("proxy_days_seen_30d") + private final Integer proxyDaysSeen30d; + @JsonProperty("proxy_days_seen_7d") + private final Integer proxyDaysSeen7d; + @JsonProperty("proxy_provider_count_30d") + private final Integer proxyProviderCount30d; + private final Double botConfidenceScore; + private final String botOperatorName; + private final String botType; + private final Boolean isKnownGoodBot; + private final String botLastSeen; + private final Boolean isCorporateGateway; + private final String corporateGatewayType; + private final String corporateGatewayProviderName; + + public Security( + Double threatScore, + Boolean isTor, + Boolean isProxy, + List proxyProviderNames, + Double proxyConfidenceScore, + String proxyLastSeen, + Boolean isResidentialProxy, + Boolean isVpn, + List vpnProviderNames, + Double vpnConfidenceScore, + String vpnLastSeen, + Boolean isRelay, + String relayProviderName, + Boolean isAnonymous, + Boolean isKnownAttacker, + Boolean isBot, + Boolean isSpam, + Boolean isCloudProvider, + String cloudProviderName) { + this(threatScore, isTor, isProxy, proxyProviderNames, proxyConfidenceScore, proxyLastSeen, + isResidentialProxy, isVpn, vpnProviderNames, vpnConfidenceScore, vpnLastSeen, isRelay, + relayProviderName, isAnonymous, isKnownAttacker, isBot, isSpam, isCloudProvider, cloudProviderName, + null, null, null, null, null, null, null, null, null, null, null); + } + @JsonCreator public Security( @JsonProperty("threat_score") Double threatScore, @@ -60,7 +101,18 @@ public Security( @JsonProperty("is_bot") Boolean isBot, @JsonProperty("is_spam") Boolean isSpam, @JsonProperty("is_cloud_provider") Boolean isCloudProvider, - @JsonProperty("cloud_provider_name") String cloudProviderName) { + @JsonProperty("cloud_provider_name") String cloudProviderName, + @JsonProperty("proxy_days_seen_30d") Integer proxyDaysSeen30d, + @JsonProperty("proxy_days_seen_7d") Integer proxyDaysSeen7d, + @JsonProperty("proxy_provider_count_30d") Integer proxyProviderCount30d, + @JsonProperty("bot_confidence_score") Double botConfidenceScore, + @JsonProperty("bot_operator_name") String botOperatorName, + @JsonProperty("bot_type") String botType, + @JsonProperty("is_known_good_bot") Boolean isKnownGoodBot, + @JsonProperty("bot_last_seen") String botLastSeen, + @JsonProperty("is_corporate_gateway") Boolean isCorporateGateway, + @JsonProperty("corporate_gateway_type") String corporateGatewayType, + @JsonProperty("corporate_gateway_provider_name") String corporateGatewayProviderName) { this.threatScore = threatScore; this.isTor = isTor; this.isProxy = isProxy; @@ -80,6 +132,17 @@ public Security( this.isSpam = isSpam; this.isCloudProvider = isCloudProvider; this.cloudProviderName = cloudProviderName; + this.proxyDaysSeen30d = proxyDaysSeen30d; + this.proxyDaysSeen7d = proxyDaysSeen7d; + this.proxyProviderCount30d = proxyProviderCount30d; + this.botConfidenceScore = botConfidenceScore; + this.botOperatorName = botOperatorName; + this.botType = botType; + this.isKnownGoodBot = isKnownGoodBot; + this.botLastSeen = botLastSeen; + this.isCorporateGateway = isCorporateGateway; + this.corporateGatewayType = corporateGatewayType; + this.corporateGatewayProviderName = corporateGatewayProviderName; } public Double threatScore() { return threatScore; } @@ -108,6 +171,18 @@ public Security( public Boolean isCloudProvider() { return isCloudProvider; } public String cloudProviderName() { return cloudProviderName; } + public Integer proxyDaysSeen30d() { return proxyDaysSeen30d; } + public Integer proxyDaysSeen7d() { return proxyDaysSeen7d; } + public Integer proxyProviderCount30d() { return proxyProviderCount30d; } + public Double botConfidenceScore() { return botConfidenceScore; } + public String botOperatorName() { return botOperatorName; } + public String botType() { return botType; } + public Boolean isKnownGoodBot() { return isKnownGoodBot; } + public String botLastSeen() { return botLastSeen; } + public Boolean isCorporateGateway() { return isCorporateGateway; } + public String corporateGatewayType() { return corporateGatewayType; } + public String corporateGatewayProviderName() { return corporateGatewayProviderName; } + @Override public boolean equals(Object other) { if (this == other) { @@ -135,7 +210,18 @@ public boolean equals(Object other) { && Objects.equals(isBot, that.isBot) && Objects.equals(isSpam, that.isSpam) && Objects.equals(isCloudProvider, that.isCloudProvider) - && Objects.equals(cloudProviderName, that.cloudProviderName); + && Objects.equals(cloudProviderName, that.cloudProviderName) + && Objects.equals(proxyDaysSeen30d, that.proxyDaysSeen30d) + && Objects.equals(proxyDaysSeen7d, that.proxyDaysSeen7d) + && Objects.equals(proxyProviderCount30d, that.proxyProviderCount30d) + && Objects.equals(botConfidenceScore, that.botConfidenceScore) + && Objects.equals(botOperatorName, that.botOperatorName) + && Objects.equals(botType, that.botType) + && Objects.equals(isKnownGoodBot, that.isKnownGoodBot) + && Objects.equals(botLastSeen, that.botLastSeen) + && Objects.equals(isCorporateGateway, that.isCorporateGateway) + && Objects.equals(corporateGatewayType, that.corporateGatewayType) + && Objects.equals(corporateGatewayProviderName, that.corporateGatewayProviderName); } @Override @@ -159,7 +245,18 @@ public int hashCode() { isBot, isSpam, isCloudProvider, - cloudProviderName); + cloudProviderName, + proxyDaysSeen30d, + proxyDaysSeen7d, + proxyProviderCount30d, + botConfidenceScore, + botOperatorName, + botType, + isKnownGoodBot, + botLastSeen, + isCorporateGateway, + corporateGatewayType, + corporateGatewayProviderName); } @Override @@ -202,6 +299,28 @@ public String toString() { + isCloudProvider + ", cloudProviderName='" + cloudProviderName - + "'}"; + + "', proxyDaysSeen30d=" + + proxyDaysSeen30d + + ", proxyDaysSeen7d=" + + proxyDaysSeen7d + + ", proxyProviderCount30d=" + + proxyProviderCount30d + + ", botConfidenceScore=" + + botConfidenceScore + + ", botOperatorName=" + + botOperatorName + + ", botType=" + + botType + + ", isKnownGoodBot=" + + isKnownGoodBot + + ", botLastSeen=" + + botLastSeen + + ", isCorporateGateway=" + + isCorporateGateway + + ", corporateGatewayType=" + + corporateGatewayType + + ", corporateGatewayProviderName=" + + corporateGatewayProviderName + + "}"; } } diff --git a/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientErrorMappingTest.java b/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientErrorMappingTest.java index 63418da..852f1ee 100644 --- a/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientErrorMappingTest.java +++ b/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientErrorMappingTest.java @@ -90,7 +90,7 @@ void preservesApiErrorDetails() { IpGeolocationClient client = new IpGeolocationClient( - IpGeolocationClientConfig.builder("k") + IpGeolocationClientConfig.builder("test-key") .build(), executor, ObjectMapperFactory.create()); diff --git a/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientRequestBuildingTest.java b/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientRequestBuildingTest.java index e27859e..d87d523 100644 --- a/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientRequestBuildingTest.java +++ b/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientRequestBuildingTest.java @@ -7,9 +7,40 @@ import java.net.URI; import java.time.Duration; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; class IpGeolocationClientRequestBuildingTest { + @ParameterizedTest + @EnumSource(Language.class) + void sendsEveryLanguageForTypedAndRawSingleAndBulkLookups(Language language) { + TestHttpExecutor executor = new TestHttpExecutor(); + String item = "{\"ip\":\"8.8.8.8\",\"location\":{\"country_name\":\"الولايات المتحدة\"}}"; + executor.enqueueResponse(200, item); + executor.enqueueResponse(200, item); + executor.enqueueResponse(200, "[" + item + "]"); + executor.enqueueResponse(200, "[" + item + "]"); + IpGeolocationClient client = new IpGeolocationClient( + IpGeolocationClientConfig.builder("test-key").build(), + executor, ObjectMapperFactory.create()); + LookupIpGeolocationRequest single = LookupIpGeolocationRequest.builder() + .ip("8.8.8.8").lang(language).build(); + BulkLookupIpGeolocationRequest bulk = BulkLookupIpGeolocationRequest.builder() + .addIp("8.8.8.8").lang(language).build(); + + assertThat(client.lookupIpGeolocation(single).data().location().countryName()) + .isEqualTo("الولايات المتحدة"); + assertThat(client.lookupIpGeolocationRaw(single).data()).isEqualTo(item); + assertThat(client.bulkLookupIpGeolocation(bulk).data()).hasSize(1); + assertThat(client.bulkLookupIpGeolocationRaw(bulk).data()).isEqualTo("[" + item + "]"); + + assertThat(executor.capturedRequests()).hasSize(4); + for (HttpRequestData request : executor.capturedRequests()) { + assertThat(request.uri().getQuery()).contains("lang=" + language.code()); + } + } + @Test void lookupBuildsExpectedQueryAndHeaders() { TestHttpExecutor executor = new TestHttpExecutor(); diff --git a/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientSecurityTest.java b/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientSecurityTest.java new file mode 100644 index 0000000..6088f50 --- /dev/null +++ b/src/test/java/io/ipgeolocation/sdk/IpGeolocationClientSecurityTest.java @@ -0,0 +1,164 @@ +package io.ipgeolocation.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +import io.ipgeolocation.sdk.exceptions.ApiException; +import io.ipgeolocation.sdk.exceptions.SerializationException; +import io.ipgeolocation.sdk.exceptions.TransportException; +import io.ipgeolocation.sdk.exceptions.UnauthorizedException; +import io.ipgeolocation.sdk.internal.ObjectMapperFactory; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectOutputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class IpGeolocationClientSecurityTest { + private static final String KEY = "dummy-test-key/+ with space"; + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3}) + void redactsApiErrorsForEveryLookupMethod(int operation) throws Exception { + String encoded = URLEncoder.encode(KEY, "UTF-8").replace("+", "%20"); + TestHttpExecutor executor = new TestHttpExecutor(); + executor.enqueueResponse(401, "{\"message\":\"Invalid " + KEY + " or " + encoded + "\"}"); + Throwable error = catchThrowable(() -> execute(client(executor), operation)); + assertThat(error).isInstanceOf(UnauthorizedException.class); + assertThat(((ApiException) error).statusCode()).isEqualTo(401); + assertThat(((ApiException) error).apiMessage()).isEqualTo("Invalid [REDACTED] or [REDACTED]"); + assertNoKey(error); + } + + @Test + void redactsBeforeTruncatingNonJsonErrors() throws Exception { + TestHttpExecutor executor = new TestHttpExecutor(); + String prefix = String.join("", Collections.nCopies(500, "x")); + executor.enqueueResponse(503, prefix + KEY); + Throwable error = catchThrowable(() -> execute(client(executor), 0)); + assertThat(((ApiException) error).apiMessage()).isEqualTo(prefix + "[REDACTED]"); + assertNoKey(error); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3}) + void preservesStatusWhenErrorBodyIsJsonNull(int operation) { + TestHttpExecutor executor = new TestHttpExecutor(); + executor.enqueueResponse(401, "null"); + Throwable error = catchThrowable(() -> execute(client(executor), operation)); + assertThat(error).isInstanceOf(UnauthorizedException.class); + assertThat(((ApiException) error).statusCode()).isEqualTo(401); + assertThat(((ApiException) error).apiMessage()).isNull(); + } + + @ParameterizedTest + @ValueSource(ints = {0, 2}) + void detachesParsingErrorsThatRetainSensitiveValues(int operation) throws Exception { + TestHttpExecutor executor = new TestHttpExecutor(); + String body = "{\"ip\":\"8.8.8.8\",\"security\":{\"threat_score\":\"" + KEY + "\"}}"; + executor.enqueueResponse(200, operation == 2 ? "[" + body + "]" : body); + Throwable error = catchThrowable(() -> execute(client(executor), operation)); + assertThat(error).isInstanceOf(SerializationException.class); + assertThat(error.getCause()).isExactlyInstanceOf(IOException.class); + assertNoKey(error); + } + + @Test + void removesNestedAndSuppressedTransportSecrets() throws Exception { + IOException original = new IOException("Unable to read " + KEY, new IOException(KEY)); + original.addSuppressed(new IOException(KEY)); + TestHttpExecutor executor = new TestHttpExecutor(); + executor.enqueueIOException(original); + Throwable error = catchThrowable(() -> execute(client(executor), 0)); + assertThat(error).isInstanceOf(TransportException.class); + assertThat(error.getCause()).isNotSameAs(original); + assertThat(error.getCause().getCause()).isNull(); + assertThat(error.getCause().getSuppressed()).isEmpty(); + assertNoKey(error); + } + + @ParameterizedTest + @ValueSource(ints = {467, 480, 499, 500, 1000}) + void parsingErrorsCannotExposePartOfATruncatedKey(int prefixLength) throws Exception { + TestHttpExecutor executor = new TestHttpExecutor(); + String prefix = String.join("", Collections.nCopies(prefixLength, "x")); + executor.enqueueResponse(200, + "{\"security\":{\"threat_score\":\"" + prefix + KEY + prefix + "\"}}"); + Throwable error = catchThrowable(() -> execute(client(executor), 0)); + assertThat(error).isInstanceOf(SerializationException.class); + assertNoKey(error); + assertThat(error.getCause().getMessage()).isEqualTo( + "com.fasterxml.jackson.databind.exc.InvalidFormatException"); + } + + @Test + void invalidBaseUrlDoesNotExposeQueryValues() throws Exception { + IpGeolocationClient client = new IpGeolocationClient( + IpGeolocationClientConfig.builder(KEY).baseUrl("https://invalid host").build()); + Throwable error = catchThrowable(() -> execute(client, 0)); + assertThat(error).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid API request URI"); + assertNoKey(error); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "", "[null]", "[true]", "[5]", "[\"text\"]"}) + void malformedBulkResultsUseSerializationException(String body) { + TestHttpExecutor executor = new TestHttpExecutor(); + executor.enqueueResponse(200, body); + assertThatThrownBy(() -> execute(client(executor), 2)).isInstanceOf(SerializationException.class); + } + + @Test + void nullSingleResultUsesSerializationException() { + TestHttpExecutor executor = new TestHttpExecutor(); + executor.enqueueResponse(200, "null"); + assertThatThrownBy(() -> execute(client(executor), 0)).isInstanceOf(SerializationException.class); + } + + @Test + void successfulResponsesAreNotRedacted() { + TestHttpExecutor executor = new TestHttpExecutor(); + String body = "{\"ip\":\"" + KEY + "\"}"; + executor.enqueueResponse(200, body); + executor.enqueueResponse(200, body); + IpGeolocationClient client = client(executor); + LookupIpGeolocationRequest request = LookupIpGeolocationRequest.builder().build(); + assertThat(client.lookupIpGeolocation(request).data().ip()).isEqualTo(KEY); + assertThat(client.lookupIpGeolocationRaw(request).data()).isEqualTo(body); + } + + private static IpGeolocationClient client(TestHttpExecutor executor) { + return new IpGeolocationClient(IpGeolocationClientConfig.builder(KEY).build(), + executor, ObjectMapperFactory.create()); + } + + private static Object execute(IpGeolocationClient client, int operation) { + LookupIpGeolocationRequest single = LookupIpGeolocationRequest.builder().ip("8.8.8.8").build(); + BulkLookupIpGeolocationRequest bulk = BulkLookupIpGeolocationRequest.builder().addIp("8.8.8.8").build(); + switch (operation) { + case 0: return client.lookupIpGeolocation(single); + case 1: return client.lookupIpGeolocationRaw(single); + case 2: return client.bulkLookupIpGeolocation(bulk); + default: return client.bulkLookupIpGeolocationRaw(bulk); + } + } + + private static void assertNoKey(Throwable error) throws Exception { + StringWriter text = new StringWriter(); + error.printStackTrace(new PrintWriter(text)); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream output = new ObjectOutputStream(bytes)) { output.writeObject(error); } + String encoded = URLEncoder.encode(KEY, "UTF-8").replace("+", "%20"); + assertThat(text.toString()).doesNotContain(KEY, encoded); + assertThat(new String(bytes.toByteArray(), StandardCharsets.ISO_8859_1)) + .doesNotContain(KEY, encoded); + } +} diff --git a/src/test/java/io/ipgeolocation/sdk/IpGeolocationLiveIntegrationTest.java b/src/test/java/io/ipgeolocation/sdk/IpGeolocationLiveIntegrationTest.java index bcc0546..fb57ed7 100644 --- a/src/test/java/io/ipgeolocation/sdk/IpGeolocationLiveIntegrationTest.java +++ b/src/test/java/io/ipgeolocation/sdk/IpGeolocationLiveIntegrationTest.java @@ -30,6 +30,33 @@ static void beforeAll() { Assumptions.assumeTrue(!TestSupport.isBlank(paidKey), "IPGEO_PAID_KEY is required"); } + @Test + void paidPlanArabicWorksForSingleAndBulk() { + try (IpGeolocationClient client = new IpGeolocationClient(IpGeolocationClientConfig.builder(paidKey).build())) { + ApiResponse single = + client.lookupIpGeolocation(LookupIpGeolocationRequest.builder() + .ip("8.8.8.8").lang(Language.AR).build()); + String countryName = single.data().location().countryName(); + assertThat(countryName).containsPattern("[\\u0600-\\u06ff]"); + + ApiResponse> bulk = client.bulkLookupIpGeolocation( + BulkLookupIpGeolocationRequest.builder().addIp("8.8.8.8").lang(Language.AR).build()); + assertThat(bulk.data()).hasSize(1); + assertThat(bulk.data().get(0)).isInstanceOf(BulkLookupSuccess.class); + assertThat(((BulkLookupSuccess) bulk.data().get(0)).item().location().countryName()) + .isEqualTo(countryName); + } + } + + @Test + void freePlanArabicIsRejected() { + try (IpGeolocationClient client = new IpGeolocationClient(IpGeolocationClientConfig.builder(freeKey).build())) { + assertThatThrownBy(() -> client.lookupIpGeolocation(LookupIpGeolocationRequest.builder() + .ip("8.8.8.8").lang(Language.AR).build())) + .isInstanceOf(UnauthorizedException.class); + } + } + @Test void freePlanBaseLookupWorks() { try (IpGeolocationClient client = new IpGeolocationClient(IpGeolocationClientConfig.builder(freeKey).build())) { @@ -104,17 +131,6 @@ void freePlanIncludeStarReturnsDefaultResponseWithoutAdditionalModules() { } } - @Test - void freePlanBulkIsRejected() { - try (IpGeolocationClient client = new IpGeolocationClient(IpGeolocationClientConfig.builder(freeKey).build())) { - assertThatThrownBy( - () -> - client.bulkLookupIpGeolocation( - BulkLookupIpGeolocationRequest.builder().addIp("8.8.8.8").build())) - .isInstanceOf(UnauthorizedException.class); - } - } - @Test void paidPlanSecurityAndAbuseWorks() { try (IpGeolocationClient client = new IpGeolocationClient(IpGeolocationClientConfig.builder(paidKey).build())) { diff --git a/src/test/java/io/ipgeolocation/sdk/LanguageTest.java b/src/test/java/io/ipgeolocation/sdk/LanguageTest.java index 0f0d9c1..183215d 100644 --- a/src/test/java/io/ipgeolocation/sdk/LanguageTest.java +++ b/src/test/java/io/ipgeolocation/sdk/LanguageTest.java @@ -3,7 +3,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.util.Locale; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; class LanguageTest { @@ -11,6 +14,15 @@ class LanguageTest { void fromCodeIsCaseInsensitive() { assertThat(Language.fromCode("EN")).isEqualTo(Language.EN); assertThat(Language.fromCode("pT")).isEqualTo(Language.PT); + assertThat(Language.fromCode("aR")).isEqualTo(Language.AR); + assertThat(Language.AR.code()).isEqualTo("ar"); + } + + @ParameterizedTest + @EnumSource(Language.class) + void everyLanguageRetainsItsWireCode(Language language) { + assertThat(language.code()).isEqualTo(language.name().toLowerCase(Locale.ROOT)); + assertThat(Language.fromCode(language.code())).isEqualTo(language); } @Test @@ -18,5 +30,7 @@ void fromCodeRejectsUnsupportedLanguage() { assertThatThrownBy(() -> Language.fromCode("zz")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Unsupported language code: zz"); + assertThatThrownBy(() -> Language.fromCode(null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Language.fromCode("")).isInstanceOf(IllegalArgumentException.class); } } diff --git a/src/test/java/io/ipgeolocation/sdk/NetworkTest.java b/src/test/java/io/ipgeolocation/sdk/NetworkTest.java new file mode 100644 index 0000000..0b28aca --- /dev/null +++ b/src/test/java/io/ipgeolocation/sdk/NetworkTest.java @@ -0,0 +1,32 @@ +package io.ipgeolocation.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.ipgeolocation.sdk.internal.ObjectMapperFactory; +import io.ipgeolocation.sdk.model.Network; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class NetworkTest { + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void preservesCdnFlagInTypedResponses(boolean value) throws Exception { + Network network = ObjectMapperFactory.create().readValue( + "{\"is_cdn\":" + value + ",\"cdn_provider_name\":\"Example CDN\"}", Network.class); + assertThat(network.isCdn()).isEqualTo(value); + assertThat(network.cdnProviderName()).isEqualTo("Example CDN"); + assertThat(ObjectMapperFactory.create().valueToTree(network).get("is_cdn").booleanValue()) + .isEqualTo(value); + } + + @Test + void retainsExistingConstructorAndMissingFieldBehavior() throws Exception { + Network oldConstructor = new Network("corporate", "8.8.8.0/24", true); + Network parsed = ObjectMapperFactory.create().readValue( + "{\"connection_type\":\"corporate\",\"route\":\"8.8.8.0/24\",\"is_anycast\":true}", Network.class); + assertThat(parsed).isEqualTo(oldConstructor).hasSameHashCodeAs(oldConstructor); + assertThat(parsed.isCdn()).isNull(); + assertThat(parsed.cdnProviderName()).isNull(); + } +} diff --git a/src/test/java/io/ipgeolocation/sdk/SecurityFieldsTest.java b/src/test/java/io/ipgeolocation/sdk/SecurityFieldsTest.java new file mode 100644 index 0000000..4d16935 --- /dev/null +++ b/src/test/java/io/ipgeolocation/sdk/SecurityFieldsTest.java @@ -0,0 +1,49 @@ +package io.ipgeolocation.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.ipgeolocation.sdk.internal.ObjectMapperFactory; +import io.ipgeolocation.sdk.model.Security; +import java.util.Iterator; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SecurityFieldsTest { + @Test + void preservesCurrentProxyBotAndGatewayFields() throws Exception { + String body = "{\"proxy_days_seen_30d\":30,\"proxy_days_seen_7d\":0," + + "\"proxy_provider_count_30d\":2,\"bot_confidence_score\":99.5," + + "\"bot_operator_name\":\"Example\",\"bot_type\":\"crawler\"," + + "\"is_known_good_bot\":false,\"bot_last_seen\":\"2026-11-03\"," + + "\"is_corporate_gateway\":true,\"corporate_gateway_type\":\"sase\"," + + "\"corporate_gateway_provider_name\":\"Example Gateway\"}"; + ObjectMapper mapper = ObjectMapperFactory.create(); + Security security = mapper.readValue(body, Security.class); + JsonNode serialized = mapper.valueToTree(security); + Iterator> fields = mapper.readTree(body).fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + assertThat(serialized.get(field.getKey())).as(field.getKey()).isEqualTo(field.getValue()); + } + assertThat(security.proxyDaysSeen30d()).isEqualTo(30); + assertThat(security.proxyDaysSeen7d()).isZero(); + assertThat(security.proxyProviderCount30d()).isEqualTo(2); + assertThat(security.botConfidenceScore()).isEqualTo(99.5); + assertThat(security.isKnownGoodBot()).isFalse(); + assertThat(security.isCorporateGateway()).isTrue(); + } + + @Test + void retainsExistingConstructorAndMissingFieldBehavior() throws Exception { + Security original = new Security(null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null); + Security parsed = ObjectMapperFactory.create().readValue("{}", Security.class); + assertThat(parsed).isEqualTo(original).hasSameHashCodeAs(original); + assertThat(parsed.proxyDaysSeen30d()).isNull(); + assertThat(parsed.botConfidenceScore()).isNull(); + assertThat(parsed.isKnownGoodBot()).isNull(); + assertThat(parsed.isCorporateGateway()).isNull(); + } +} diff --git a/src/test/java/io/ipgeolocation/sdk/ValueObjectCoverageTest.java b/src/test/java/io/ipgeolocation/sdk/ValueObjectCoverageTest.java index b6a065d..8c834e9 100644 --- a/src/test/java/io/ipgeolocation/sdk/ValueObjectCoverageTest.java +++ b/src/test/java/io/ipgeolocation/sdk/ValueObjectCoverageTest.java @@ -142,6 +142,11 @@ private void invokeAccessors(Object value) throws Exception { private Constructor constructorFor(Class type) { Constructor[] constructors = type.getConstructors(); + for (Constructor constructor : constructors) { + if (constructor.isAnnotationPresent(com.fasterxml.jackson.annotation.JsonCreator.class)) { + return constructor; + } + } if (constructors.length != 1) { throw new IllegalStateException("Expected exactly one public constructor for " + type); } diff --git a/src/test/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutorTest.java b/src/test/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutorTest.java index 73757e2..b9a4580 100644 --- a/src/test/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutorTest.java +++ b/src/test/java/io/ipgeolocation/sdk/internal/JavaNetHttpExecutorTest.java @@ -19,9 +19,59 @@ import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; class JavaNetHttpExecutorTest { + @ParameterizedTest + @CsvSource({ + "PT0.000000001S, 1", + "PT0.000999999S, 1", + "PT0.001S, 1", + "PT0.001999999S, 1", + "PT10S, 10000", + "PT2147483.646S, 2147483646", + "PT2147483.647S, 2147483647", + "PT2147483.648S, 2147483647", + "PT9223372036854775807S, 2147483647" + }) + void appliesFiniteTimeoutsWithoutOverflow(String value, int expectedMillis) throws Exception { + Duration timeout = Duration.parse(value); + StubHttpURLConnection connection = + new StubHttpURLConnection( + url("https://api.ipgeolocation.io/v3/ipgeo"), 200, "{}", + Collections.>emptyMap()); + JavaNetHttpExecutor executor = + new JavaNetHttpExecutor(timeout, new StubConnectionFactory(connection)); + + executor.send(new HttpRequestData( + connection.getURL().toURI(), "GET", + Collections.>emptyMap(), null, timeout)); + + assertThat(connection.getConnectTimeout()).isEqualTo(expectedMillis); + assertThat(connection.getReadTimeout()).isEqualTo(expectedMillis); + assertThat(connection.disconnected).isTrue(); + } + + @ParameterizedTest + @ValueSource(longs = {0L, -1L, -1_000_000_000L}) + void rejectsNonPositiveReadTimeouts(long nanos) { + StubHttpURLConnection connection = + new StubHttpURLConnection( + url("https://api.ipgeolocation.io/v3/ipgeo"), 200, "{}", + Collections.>emptyMap()); + JavaNetHttpExecutor executor = + new JavaNetHttpExecutor(Duration.ofSeconds(1), new StubConnectionFactory(connection)); + + assertThatThrownBy(() -> executor.send(new HttpRequestData( + URI.create("https://api.ipgeolocation.io/v3/ipgeo"), "GET", + Collections.>emptyMap(), null, Duration.ofNanos(nanos)))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(connection.disconnected).isTrue(); + } + @Test void rejectsInvalidTimeoutsAndNullConnectionFactory() { assertThatThrownBy(() -> new JavaNetHttpExecutor(Duration.ZERO)) @@ -252,6 +302,7 @@ private static final class StubHttpURLConnection extends HttpURLConnection { private boolean nullInputStream; private boolean nullErrorStream; private boolean outputStreamRequested; + private boolean disconnected; private int fixedLengthStreamingMode = -1; private StubHttpURLConnection( @@ -263,7 +314,9 @@ private StubHttpURLConnection( } @Override - public void disconnect() {} + public void disconnect() { + disconnected = true; + } @Override public boolean usingProxy() {