From a3a0c3a899c911918b212af7cbc030b7c8a52241 Mon Sep 17 00:00:00 2001 From: scarreras Date: Mon, 2 Nov 2026 11:55:08 -0300 Subject: [PATCH] Fix several edge-case bugs and strengthen tests Fixes: - B2AccountAuthorizationCache: do not cache an authorization for a different account after the account-id mismatch check fails. - B2WebApiHttpClientImpl.getContent: honor the timeouts configured on the HttpClientFactory (adds default HttpClientFactory.getRequestConfig()). - B2LargeFileStorer: compute part index and start byte from the sorted part list so progress events are right for out-of-order parts. - B2CancellableContentSource: close the wrapped stream even when close() reports the cancellation. - B2EventNotificationRule: isSuspended() and compareTo() no longer throw NullPointerException when suspension fields are not provided. - B2InputStreamExcerpt.skip(): do not advance the position twice. - B2Json LocalDate/ConcurrentMap/EnumSet handlers: throw B2JsonException for invalid dates and null elements instead of unchecked exceptions. Tests: new tests for each fix, plus stronger tests for the finish-large-file request payload, content writer sha1 handling, ranged content sources, unverified sha1 headers, JSON field handling and BigInteger, resume, and error-path download tests that could never fail. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 12 ++ .../client/B2AccountAuthorizationCache.java | 3 +- .../b2/client/B2CancellableContentSource.java | 9 +- .../b2/client/B2LargeFileStorer.java | 5 +- .../structures/B2EventNotificationRule.java | 4 +- .../b2/json/B2JsonConcurrentMapHandler.java | 4 + .../b2/json/B2JsonEnumSetHandler.java | 7 +- .../b2/json/B2JsonLocalDateHandler.java | 32 ++++- .../b2/util/B2InputStreamExcerpt.java | 7 +- .../B2AccountAuthorizationCacheTest.java | 22 ++++ .../B2CancellableContentSourceTest.java | 15 +++ .../b2/client/B2LargeFileStorerTest.java | 59 +++++++++ .../b2/client/B2LargeFileUploaderTest.java | 119 ++++++++++++++++++ .../b2/client/B2StorageClientImplTest.java | 51 ++++++++ .../contentHandlers/B2ContentWriterTest.java | 20 +++ .../contentSources/B2HeadersImplTest.java | 5 + .../B2EventNotificationRuleTest.java | 27 ++++ .../com/backblaze/b2/json/B2JsonTest.java | 67 +++++++++- .../b2/util/B2InputStreamExcerptTest.java | 23 ++++ .../B2WebApiHttpClientImpl.java | 2 +- .../webApiHttpClient/HttpClientFactory.java | 10 ++ .../HttpClientFactoryImpl.java | 5 + .../B2WebApiHttpClientImplTest.java | 2 + .../B2WebApiHttpClientImplTimeoutTest.java | 43 +++++++ 24 files changed, 532 insertions(+), 21 deletions(-) create mode 100644 httpclient/src/test/java/com/backblaze/b2/client/webApiHttpClient/B2WebApiHttpClientImplTimeoutTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c4922260..45c020371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,16 @@ # Changelog +## [Unreleased] +### Fixed +* `B2AccountAuthorizationCache` no longer caches (and returns on later calls) an authorization for a different account after the account-id mismatch check fails. +* `B2WebApiHttpClientImpl.getContent` now honors the connect, socket and pool timeouts configured on the `HttpClientFactory`. Added a default `HttpClientFactory.getRequestConfig()` that `HttpClientFactoryImpl` overrides. +* `B2LargeFileStorer` computes part indexes and start bytes from the sorted part list, so progress events are correct when parts are supplied out of order. +* `B2CancellableContentSource`'s input stream now closes the wrapped stream even when `close()` reports the cancellation. +* `B2EventNotificationRule.isSuspended()` and `compareTo()` no longer throw `NullPointerException` when suspension fields were not provided. +* `B2InputStreamExcerpt.skip()` no longer advances the position twice. +* JSON handlers for `LocalDate`, `ConcurrentMap` and `EnumSet` throw `B2JsonException` for invalid dates and null elements instead of unchecked exceptions. +### Tests +* Added and strengthened unit tests for the above, for the finish-large-file request payload, content writer sha1 handling, and several previously vacuous tests. + ## [6.5.0] - 2025-12-15 * Add `readBucketLifecycleRules` and `writeBucketLifecycleRules` capabilities. ## [6.4.0] - 2025-05-14 diff --git a/core/src/main/java/com/backblaze/b2/client/B2AccountAuthorizationCache.java b/core/src/main/java/com/backblaze/b2/client/B2AccountAuthorizationCache.java index 5a3e01047..5a108293d 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2AccountAuthorizationCache.java +++ b/core/src/main/java/com/backblaze/b2/client/B2AccountAuthorizationCache.java @@ -56,8 +56,6 @@ B2AccountAuthorization get() throws B2Exception { localAuthorization = authorization; if (localAuthorization == null) { localAuthorization = accountAuthorizer.authorize(webifier); - authorization = localAuthorization; - final String accountIdFromAuthorization = localAuthorization.getAccountId(); if (accountId == null) { accountId = accountIdFromAuthorization; @@ -67,6 +65,7 @@ B2AccountAuthorization get() throws B2Exception { "but previously authorized as accountId " + accountId); } } + authorization = localAuthorization; } return localAuthorization; } diff --git a/core/src/main/java/com/backblaze/b2/client/B2CancellableContentSource.java b/core/src/main/java/com/backblaze/b2/client/B2CancellableContentSource.java index 4cf8a71ea..66c82a16b 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2CancellableContentSource.java +++ b/core/src/main/java/com/backblaze/b2/client/B2CancellableContentSource.java @@ -95,9 +95,12 @@ public int available() throws IOException { @Override public void close() throws IOException { - throwIfCancelled(); - - source.close(); + try { + throwIfCancelled(); + } finally { + // always release the wrapped stream, even if we report the cancellation. + source.close(); + } } @Override diff --git a/core/src/main/java/com/backblaze/b2/client/B2LargeFileStorer.java b/core/src/main/java/com/backblaze/b2/client/B2LargeFileStorer.java index 26e0882db..fe1c4f3ca 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2LargeFileStorer.java +++ b/core/src/main/java/com/backblaze/b2/client/B2LargeFileStorer.java @@ -97,8 +97,9 @@ public class B2LargeFileStorer { this.largeFileId = storeLargeFileRequest.getFileId(); this.serverSideEncryptionOrNull = storeLargeFileRequest.getServerSideEncryption(); this.partStorers = validateAndSortPartStorers(new ArrayList<>(partStorers), partNumberGapsAllowed); - this.startingBytePositions = computeStartingBytePositions(partStorers); - this.indexesByPartNumber = computeIndexesByPartNumbers(partStorers); + // use the sorted list: positions and indexes are looked up by part number, in part order. + this.startingBytePositions = computeStartingBytePositions(this.partStorers); + this.indexesByPartNumber = computeIndexesByPartNumbers(this.partStorers); this.accountAuthCache = accountAuthCache; this.uploadPartUrlCache = new B2UploadPartUrlCache(webifier, accountAuthCache, largeFileId); diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationRule.java b/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationRule.java index a46734c3a..c97b35c19 100644 --- a/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationRule.java +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationRule.java @@ -20,7 +20,7 @@ public class B2EventNotificationRule implements Comparable rule.getTargetConfiguration().toString()) .thenComparing(B2EventNotificationRule::isEnabled) .thenComparing(B2EventNotificationRule::isSuspended) - .thenComparing(B2EventNotificationRule::getSuspensionReason); + .thenComparing(B2EventNotificationRule::getSuspensionReason, Comparator.nullsFirst(Comparator.naturalOrder())); /** * A name for identifying the rule. Names must be unique within a bucket. @@ -118,7 +118,7 @@ public boolean isEnabled() { } public boolean isSuspended() { - return isSuspended; + return Boolean.TRUE.equals(isSuspended); } public String getSuspensionReason() { diff --git a/core/src/main/java/com/backblaze/b2/json/B2JsonConcurrentMapHandler.java b/core/src/main/java/com/backblaze/b2/json/B2JsonConcurrentMapHandler.java index d35524ca4..8c735cd15 100644 --- a/core/src/main/java/com/backblaze/b2/json/B2JsonConcurrentMapHandler.java +++ b/core/src/main/java/com/backblaze/b2/json/B2JsonConcurrentMapHandler.java @@ -49,6 +49,10 @@ public ConcurrentMap deserialize(B2JsonReader in, B2JsonOptions options) throws Object key = keyHandler.deserialize(in, options); in.skipObjectColon(); Object value = B2JsonUtil.deserializeMaybeNull(valueHandler, in, options); + if (value == null) { + // ConcurrentHashMap cannot hold null values. + throw new B2JsonException("null value is not allowed in a ConcurrentMap"); + } result.put(key, value); } while (in.objectHasMoreFields()); } diff --git a/core/src/main/java/com/backblaze/b2/json/B2JsonEnumSetHandler.java b/core/src/main/java/com/backblaze/b2/json/B2JsonEnumSetHandler.java index 7ec0d0491..059bd3b66 100644 --- a/core/src/main/java/com/backblaze/b2/json/B2JsonEnumSetHandler.java +++ b/core/src/main/java/com/backblaze/b2/json/B2JsonEnumSetHandler.java @@ -48,8 +48,13 @@ public EnumSet deserialize(B2JsonReader in, B2JsonOptions options) throws B2Json EnumSet result = EnumSet.noneOf(handledClass); if (in.startArrayAndCheckForContents()) { do { + final Object item = B2JsonUtil.deserializeMaybeNull(itemHandler, in, options); + if (item == null) { + // EnumSet cannot hold nulls. + throw new B2JsonException("null item is not allowed in an EnumSet"); + } //noinspection unchecked - result.add(B2JsonUtil.deserializeMaybeNull(itemHandler, in, options)); + result.add(item); } while (in.arrayHasMoreValues()); } in.finishArray(); diff --git a/core/src/main/java/com/backblaze/b2/json/B2JsonLocalDateHandler.java b/core/src/main/java/com/backblaze/b2/json/B2JsonLocalDateHandler.java index 4d027ec7b..94431938c 100644 --- a/core/src/main/java/com/backblaze/b2/json/B2JsonLocalDateHandler.java +++ b/core/src/main/java/com/backblaze/b2/json/B2JsonLocalDateHandler.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.lang.reflect.Type; +import java.time.DateTimeException; import java.time.LocalDate; import static com.backblaze.b2.util.B2DateTimeUtil.MAX_DAY; @@ -38,7 +39,7 @@ public LocalDate deserialize(B2JsonReader in, B2JsonOptions options) throws B2Js B2StringUtil.decimalNumberInRange(str, 0, 4, MIN_YEAR, MAX_YEAR) && B2StringUtil.decimalNumberInRange(str, 5, 2, MIN_MONTH, MAX_MONTH) && B2StringUtil.decimalNumberInRange(str, 8, 2, MIN_DAY, MAX_DAY)) { - return LocalDate.parse(str); + return parseIso(str); } // Try Backblaze style: "20150315" @@ -46,10 +47,11 @@ public LocalDate deserialize(B2JsonReader in, B2JsonOptions options) throws B2Js B2StringUtil.decimalNumberInRange(str, 0, 4, MIN_YEAR, MAX_YEAR) && B2StringUtil.decimalNumberInRange(str, 4, 2, MIN_MONTH, MAX_MONTH) && B2StringUtil.decimalNumberInRange(str, 6, 2, MIN_DAY, MAX_DAY)) { - return LocalDate.of( + return dateOf( B2StringUtil.decimalSubstringToInt(str, 0, 4), B2StringUtil.decimalSubstringToInt(str, 4, 6), - B2StringUtil.decimalSubstringToInt(str, 6, 8) + B2StringUtil.decimalSubstringToInt(str, 6, 8), + str ); } throw new B2JsonException("not a valid date: " + str); @@ -63,7 +65,7 @@ public LocalDate deserializeUrlParam(String str) throws B2JsonException { B2StringUtil.decimalNumberInRange(str, 0, 4, MIN_YEAR, MAX_YEAR) && B2StringUtil.decimalNumberInRange(str, 5, 2, MIN_MONTH, MAX_MONTH) && B2StringUtil.decimalNumberInRange(str, 8, 2, MIN_DAY, MAX_DAY)) { - return LocalDate.parse(str); + return parseIso(str); } // Try Backblaze style: "20150315" @@ -71,15 +73,33 @@ public LocalDate deserializeUrlParam(String str) throws B2JsonException { B2StringUtil.decimalNumberInRange(str, 0, 4, MIN_YEAR, MAX_YEAR) && B2StringUtil.decimalNumberInRange(str, 4, 2, MIN_MONTH, MAX_MONTH) && B2StringUtil.decimalNumberInRange(str, 6, 2, MIN_DAY, MAX_DAY)) { - return LocalDate.of( + return dateOf( B2StringUtil.decimalSubstringToInt(str, 0, 4), B2StringUtil.decimalSubstringToInt(str, 4, 6), - B2StringUtil.decimalSubstringToInt(str, 6, 8) + B2StringUtil.decimalSubstringToInt(str, 6, 8), + str ); } throw new B2JsonException("not a valid date: " + str); } + // The range checks accept days 29 to 31 for every month, so the calendar can still reject the date. + private static LocalDate parseIso(String str) throws B2JsonException { + try { + return LocalDate.parse(str); + } catch (DateTimeException e) { + throw new B2JsonException("not a valid date: " + str); + } + } + + private static LocalDate dateOf(int year, int month, int day, String str) throws B2JsonException { + try { + return LocalDate.of(year, month, day); + } catch (DateTimeException e) { + throw new B2JsonException("not a valid date: " + str); + } + } + public LocalDate defaultValueForOptional() { return null; } diff --git a/core/src/main/java/com/backblaze/b2/util/B2InputStreamExcerpt.java b/core/src/main/java/com/backblaze/b2/util/B2InputStreamExcerpt.java index b067661f4..87c49bfde 100644 --- a/core/src/main/java/com/backblaze/b2/util/B2InputStreamExcerpt.java +++ b/core/src/main/java/com/backblaze/b2/util/B2InputStreamExcerpt.java @@ -82,9 +82,10 @@ public long skip(long n) throws IOException { if (atEnd()) { return 0; } - final long actuallySkipped = super.skip(Math.min(n,getRemaining())); - iPosition += actuallySkipped; - return actuallySkipped; + // super.skip() reads through our own read(), which already advances iPosition. + final long positionBefore = iPosition; + super.skip(Math.min(n,getRemaining())); + return iPosition - positionBefore; } @Override diff --git a/core/src/test/java/com/backblaze/b2/client/B2AccountAuthorizationCacheTest.java b/core/src/test/java/com/backblaze/b2/client/B2AccountAuthorizationCacheTest.java index 9696abd9e..40ddcae5a 100644 --- a/core/src/test/java/com/backblaze/b2/client/B2AccountAuthorizationCacheTest.java +++ b/core/src/test/java/com/backblaze/b2/client/B2AccountAuthorizationCacheTest.java @@ -105,6 +105,28 @@ public void testMismatchAccountId() throws B2Exception { } + @Test + public void testMismatchAccountIdIsNotCached() throws B2Exception { + doReturn(makeAuthWithAccountId(accountId, 1)).when(authorizer).authorize(webifier); + cache.get(); + cache.clear(); + final B2AccountAuthorization auth2 = makeAuthWithAccountId("anotherAccountId", 2); + doReturn(auth2).when(authorizer).authorize(webifier); + try { + cache.get(); + org.junit.Assert.fail("expected mismatch"); + } catch (B2LocalException expected) { + // ok + } + // second call must not hand back the wrong-account authorization + try { + final B2AccountAuthorization got = cache.get(); + org.junit.Assert.fail("wrong-account authorization was cached and returned: " + got.getAccountId()); + } catch (B2LocalException expected) { + // ok + } + } + @Test public void testGetAccountIdCallsGet() throws B2Exception { final B2AccountAuthorization auth1 = makeAuthWithAccountId(accountId, 1); diff --git a/core/src/test/java/com/backblaze/b2/client/B2CancellableContentSourceTest.java b/core/src/test/java/com/backblaze/b2/client/B2CancellableContentSourceTest.java index 4e7ad7f32..04777a899 100644 --- a/core/src/test/java/com/backblaze/b2/client/B2CancellableContentSourceTest.java +++ b/core/src/test/java/com/backblaze/b2/client/B2CancellableContentSourceTest.java @@ -154,6 +154,21 @@ public void testInputStreamCloseCancelled() throws B2Exception, IOException { inputStream.close(); } + @Test + public void testInputStreamCloseCancelledStillClosesWrappedStream() throws B2Exception, IOException { + final InputStream inputStream = cancellableContentSource.createInputStream(); + cancellationToken.cancel(); + + try { + inputStream.close(); + } catch (IOException e) { + // closing a cancelled stream is allowed to report the cancellation. + } + + // whatever close() reports, the wrapped stream (a file handle or socket) must be released. + verify(mockInputStream).close(); + } + @Test public void testInputStreamResetCancelled() throws B2Exception, IOException { final InputStream inputStream = cancellableContentSource.createInputStream(); diff --git a/core/src/test/java/com/backblaze/b2/client/B2LargeFileStorerTest.java b/core/src/test/java/com/backblaze/b2/client/B2LargeFileStorerTest.java index 5e12988db..3f5683aa6 100644 --- a/core/src/test/java/com/backblaze/b2/client/B2LargeFileStorerTest.java +++ b/core/src/test/java/com/backblaze/b2/client/B2LargeFileStorerTest.java @@ -256,6 +256,29 @@ public void testStartByte_partNumberGapsAllowed() throws IOException { assertEquals(B2UploadProgress.UNKNOWN_PART_START_BYTE, largeFileStorer.getStartByteOrUnknown(4)); } + @Test + public void testStartByte_outOfOrderPartStorers() throws IOException { + // same parts as testStartByte, but handed over out of order. positions depend on part numbers, not list order. + final List partStorers = Arrays.asList( + new B2AlreadyStoredPartStorer(part2), + new B2CopyingPartStorer(3, fileId(3)), + new B2UploadingPartStorer(1, createContentSourceWithSize(100))); + + final B2LargeFileStorer largeFileStorer = new B2LargeFileStorer( + B2StoreLargeFileRequest.builder(largeFileVersion.getFileId()).build(), + partStorers, + authCache, + webifier, + retryer, + retryPolicySupplier, + singleThreadedExecutor, + false); + + assertEquals(0, largeFileStorer.getStartByteOrUnknown(1)); + assertEquals(100, largeFileStorer.getStartByteOrUnknown(2)); + assertEquals(100 + PART_SIZE_FOR_FIRST_TWO, largeFileStorer.getStartByteOrUnknown(3)); + } + @Test(expected = IllegalArgumentException.class) public void testStartByte_partNumberTooLow() throws IOException { createLargeFileStorerForStartByteTests(false).getStartByteOrUnknown(0); @@ -480,6 +503,14 @@ private void checkCannotUpload() throws B2Exception { B2UploadState.SUCCEEDED)); } + private void drainSingleThreadedExecutor() { + try { + singleThreadedExecutor.submit(() -> { }).get(30, java.util.concurrent.TimeUnit.SECONDS); + } catch (Exception e) { + Assert.fail("executor did not drain: " + e); + } + } + @Test public void testStoreFileAsyncCancelled() throws B2Exception, IOException { final List partStorers = new ArrayList<>(); @@ -521,6 +552,11 @@ public void testStoreFileAsyncCancelled() throws B2Exception, IOException { future.get().get(); Assert.fail("we should have gotten a CancellationException"); } catch (CancellationException e) { + // The future is cancelled as soon as the first upload starts, so get() returns while the + // executor may still be running later tasks. Wait for everything already queued on the + // single-threaded executor to drain, or the verify below runs before part 2 could ever + // reach the webifier and cannot fail. + drainSingleThreadedExecutor(); // upload part should only be called once verify(webifier, times(1)).uploadPart(anyObject(), anyObject()); verify(webifier, times(0)).finishLargeFile(anyObject(), anyObject()); @@ -570,6 +606,11 @@ public void testStorePartsAsyncCancelled() throws B2Exception, IOException { future.get().get(); Assert.fail("we should have gotten a CancellationException"); } catch (CancellationException e) { + // The future is cancelled as soon as the first upload starts, so get() returns while the + // executor may still be running later tasks. Wait for everything already queued on the + // single-threaded executor to drain, or the verify below runs before part 2 could ever + // reach the webifier and cannot fail. + drainSingleThreadedExecutor(); // upload part should only be called once verify(webifier, times(1)).uploadPart(anyObject(), anyObject()); } catch (Exception e) { @@ -645,6 +686,24 @@ public void testCreateRangedContentSource() throws IOException { final B2ContentSource secondRangedContentSource = B2LargeFileStorer.createRangedContentSource( firstRangedContentSource, 10, 50); assertTrue(secondRangedContentSource instanceof B2PartOfContentSource); + } + + @Test + public void testCreateRangedContentSource_usesStartAndLength() throws IOException { + final B2ContentSource contentSource = new TestContentSource(100, 1000); + + // the source can make its own range: start is relative to the source, length is the part length. + final B2ContentSource firstRangedContentSource = B2LargeFileStorer.createRangedContentSource( + contentSource, 5, 200); + assertEquals(new TestContentSource(105, 200, false), firstRangedContentSource); + + // that one cannot, so a B2PartOfContentSource wraps it with the requested start and length. + final B2ContentSource secondRangedContentSource = B2LargeFileStorer.createRangedContentSource( + firstRangedContentSource, 10, 50); + assertTrue(secondRangedContentSource instanceof B2PartOfContentSource); + assertEquals(50, secondRangedContentSource.getContentLength()); + assertTrue(secondRangedContentSource.toString(), + secondRangedContentSource.toString().startsWith("B2PartOfContentSource{start=10, length=50, source=")); } diff --git a/core/src/test/java/com/backblaze/b2/client/B2LargeFileUploaderTest.java b/core/src/test/java/com/backblaze/b2/client/B2LargeFileUploaderTest.java index feaabf482..f09de93d0 100644 --- a/core/src/test/java/com/backblaze/b2/client/B2LargeFileUploaderTest.java +++ b/core/src/test/java/com/backblaze/b2/client/B2LargeFileUploaderTest.java @@ -211,6 +211,56 @@ public void testExceptionFromUploadPart_inResume() throws B2Exception, IOExcepti } + /** + * testExceptionFromUploadPart_inResume goes through uploadLargeFile(), which starts a new large file. + * This is the same failure through finishUploadingLargeFile(), which is the real resume path. + */ + @Test + public void testExceptionFromUploadPart_inFinishUploadingLargeFile() throws B2Exception, IOException { + final B2FileVersion largeFileVersion = new B2FileVersion( + fileId(1), + fileName(1), + contentSource.getContentLength(), + B2ContentTypes.APPLICATION_OCTET, + null, + null, + B2Collections.mapOf(), + "upload", + 123L, + null, + null, + null, + null); + + final B2GetUploadPartUrlRequest partUrlRequest = B2GetUploadPartUrlRequest.builder(largeFileVersion.getFileId()).build(); + final B2UploadPartUrlResponse partUrl = new B2UploadPartUrlResponse(largeFileVersion.getFileId(), "uploadPartUrl", "uploadPartAuthToken"); + when(webifier.getUploadPartUrl(anyObject(), eq(partUrlRequest))).thenReturn(partUrl); + + when(webifier.uploadPart(anyObject(), anyObject())).thenAnswer(invocationOnMock -> { + B2UploadPartRequest request = (B2UploadPartRequest) invocationOnMock.getArguments()[1]; + return makePart(request.getPartNumber()); + }); + + recordingListener.setExpected( + "B2UploadProgress{partIndex=0, partCount=2, startByte=0, length=1000, bytesSoFar=0, state=WAITING_TO_START}", + "B2UploadProgress{partIndex=0, partCount=2, startByte=0, length=1000, bytesSoFar=0, state=STARTING}", + "B2UploadProgress{partIndex=0, partCount=2, startByte=0, length=1000, bytesSoFar=1000, state=SUCCEEDED}", + "B2UploadProgress{partIndex=1, partCount=2, startByte=1000, length=1000, bytesSoFar=0, state=WAITING_TO_START}", + "B2UploadProgress{partIndex=1, partCount=2, startByte=1000, length=1000, bytesSoFar=0, state=STARTING}", + "B2UploadProgress{partIndex=1, partCount=2, startByte=1000, length=1000, bytesSoFar=1000, state=SUCCEEDED}" + ); + + thrown.expect(B2Exception.class); + thrown.expectMessage("testing"); + try { + makeUploader(PART_SIZES, new ThrowingExecutor(IN_GET_AFTER_RUNNING_IN_SUBMIT, ExceptionType.B2_EXCEPTION), contentSource, recordingListener) + .finishUploadingLargeFile(largeFileVersion, B2Collections.listOf()); + } finally { + // resuming must reuse the existing large file, not start another one. + verify(webifier, never()).startLargeFile(anyObject(), anyObject()); + } + } + @Test public void testExceptionFromGetSha1OrNull_inResume() throws B2Exception, IOException { // arrange to throw when asked for the sha1. @@ -486,6 +536,75 @@ public void testOneAlreadyUploadedPartThatMatches_part1() throws IOException, B2 } + /** + * Characterization of a documented limitation (by design, see similarEnough()): + * resume trusts an already-uploaded part when its part number and length match, and never + * compares its sha1 with the local content. If the owner decides to change that, this test + * should be flipped, not deleted. + */ + @Test + public void testAlreadyUploadedPartIsReusedByNumberAndLengthEvenIfSha1Differs() throws IOException, B2Exception { + final long contentLen = (3 * ACCOUNT_AUTH.getRecommendedPartSize() + 124); + final B2ContentSource contentSource = mock(B2ContentSource.class); + when(contentSource.getContentLength()).thenReturn(contentLen); + + final String largeFileId = fileId(1); + final B2FileVersion largeFileVersion = new B2FileVersion(largeFileId, + fileName(1), + contentLen, + B2ContentTypes.APPLICATION_OCTET, + null, + null, + B2Collections.mapOf(), + "upload", + B2Clock.get().wallClockMillis(), + null, + null, + null, + null); + + final String sha1OfSomeOtherContent = "ffffffffffffffffffffffffffffffffffffffff"; + final List alreadyUploadedParts = B2Collections.listOf( + new B2Part(largeFileId, 1, 1041, sha1OfSomeOtherContent, makeMd5(1), 1111, null) + ); + + recordingListener.setExpected( + "B2UploadProgress{partIndex=0, partCount=3, startByte=0, length=1041, bytesSoFar=0, state=WAITING_TO_START}", + "B2UploadProgress{partIndex=0, partCount=3, startByte=0, length=1041, bytesSoFar=1041, state=SUCCEEDED}", + "B2UploadProgress{partIndex=1, partCount=3, startByte=1041, length=1041, bytesSoFar=0, state=WAITING_TO_START}", + "B2UploadProgress{partIndex=2, partCount=3, startByte=2082, length=1042, bytesSoFar=0, state=WAITING_TO_START}", + "B2UploadProgress{partIndex=1, partCount=3, startByte=1041, length=1041, bytesSoFar=0, state=STARTING}", + "B2UploadProgress{partIndex=1, partCount=3, startByte=1041, length=1041, bytesSoFar=1041, state=SUCCEEDED}", + "B2UploadProgress{partIndex=2, partCount=3, startByte=2082, length=1042, bytesSoFar=0, state=STARTING}", + "B2UploadProgress{partIndex=2, partCount=3, startByte=2082, length=1042, bytesSoFar=1042, state=SUCCEEDED}"); + + final B2GetUploadPartUrlRequest partUrlRequest = B2GetUploadPartUrlRequest.builder(largeFileId).build(); + final B2UploadPartUrlResponse partUrl = new B2UploadPartUrlResponse(largeFileId, "uploadPartUrl", "uploadPartAuthToken"); + when(webifier.getUploadPartUrl(anyObject(), eq(partUrlRequest))).thenReturn(partUrl); + when(webifier.uploadPart(anyObject(), anyObject())).thenAnswer(invocationOnMock -> { + B2UploadPartRequest request = (B2UploadPartRequest) invocationOnMock.getArguments()[1]; + return makePart(request.getPartNumber()); + }); + when(webifier.finishLargeFile(anyObject(), anyObject())).thenReturn(largeFileVersion); + + makeUploader(PART_SIZES, Executors.newSingleThreadExecutor(), contentSource, recordingListener) + .finishUploadingLargeFile(largeFileVersion, alreadyUploadedParts); + + // part 1 was not uploaded again... + final ArgumentCaptor uploadCaptor = ArgumentCaptor.forClass(B2UploadPartRequest.class); + verify(webifier, times(2)).uploadPart(anyObject(), uploadCaptor.capture()); + assertEquals( + B2Collections.unmodifiableSet(new Integer[] {2, 3}), + uploadCaptor.getAllValues().stream().map(B2UploadPartRequest::getPartNumber).collect(Collectors.toSet())); + + // ...and the stale sha1 is what gets sent to finish the file. + final ArgumentCaptor finishCaptor = ArgumentCaptor.forClass(B2FinishLargeFileRequest.class); + verify(webifier, times(1)).finishLargeFile(anyObject(), finishCaptor.capture()); + assertEquals( + B2Collections.listOf(sha1OfSomeOtherContent, makeSha1(2), makeSha1(3)), + finishCaptor.getValue().getPartSha1Array()); + } + @Test public void testOneAlreadyUploadedPartThatMatches_part3() throws IOException, B2Exception { final String largeFileId = fileId(1); diff --git a/core/src/test/java/com/backblaze/b2/client/B2StorageClientImplTest.java b/core/src/test/java/com/backblaze/b2/client/B2StorageClientImplTest.java index 9326b6d67..10a016cc4 100644 --- a/core/src/test/java/com/backblaze/b2/client/B2StorageClientImplTest.java +++ b/core/src/test/java/com/backblaze/b2/client/B2StorageClientImplTest.java @@ -81,6 +81,7 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; +import org.mockito.ArgumentCaptor; import java.io.IOException; import java.util.ArrayList; @@ -1106,6 +1107,56 @@ public void testFinishUploadingLargeFile() throws B2Exception, IOException { verify(webifier, times(1)).finishLargeFile(anyObject(), anyObject()); } + @Test + public void testFinishUploadingLargeFile_sendsEveryPartSha1InPartOrder() throws B2Exception, IOException { + final long contentLen = (3 * ACCOUNT_AUTH.getRecommendedPartSize() + 124); + final B2ContentSource contentSource = mock(B2ContentSource.class); + when(contentSource.getContentLength()).thenReturn(contentLen); + + final B2UploadFileRequest request = B2UploadFileRequest + .builder(bucketId(1), fileName(1), B2ContentTypes.TEXT_PLAIN, contentSource) + .build(); + + final B2FileVersion largeFileVersion = new B2FileVersion(fileId(1), + fileName(1), + contentLen, + B2ContentTypes.TEXT_PLAIN, + null, + null, + B2Collections.mapOf(), + "upload", + B2Clock.get().wallClockMillis(), + null, + null, + null, + null); + final String largeFileId = largeFileVersion.getFileId(); + + // parts 1 and 3 are already on the server, with the lengths the splitter computes for this content + // (3124 bytes in 3 parts: 1041, 1041, 1042). + final List alreadyUploadedParts = listOf( + new B2Part(largeFileId, 1, 1041, makeSha1(1), makeMd5(1), 1111, null), + new B2Part(largeFileId, 3, 1042, makeSha1(3), makeMd5(3), 3333, null) + ); + when(webifier.listParts(anyObject(), anyObject())) + .thenReturn(new B2ListPartsResponse(alreadyUploadedParts, null)); + when(webifier.getUploadPartUrl(anyObject(), anyObject())) + .thenReturn(new B2UploadPartUrlResponse(largeFileId, "uploadPartUrl", "uploadPartAuthToken")); + when(webifier.uploadPart(anyObject(), anyObject())).thenReturn(makePart(2)); + when(webifier.finishLargeFile(anyObject(), anyObject())).thenReturn(largeFileVersion); + + client.finishUploadingLargeFile(largeFileVersion, request, executor); + + // only part 2 needed uploading ... + verify(webifier, times(1)).uploadPart(anyObject(), anyObject()); + + // ... and b2_finish_large_file must list all three sha1s, in part order. + final ArgumentCaptor captor = ArgumentCaptor.forClass(B2FinishLargeFileRequest.class); + verify(webifier, times(1)).finishLargeFile(anyObject(), captor.capture()); + assertEquals(largeFileId, captor.getValue().getFileId()); + assertEquals(listOf(makeSha1(1), makeSha1(2), makeSha1(3)), captor.getValue().getPartSha1Array()); + } + @Test public void testGetUploadUrl() throws B2Exception { final B2GetUploadUrlRequest request = B2GetUploadUrlRequest.builder(bucketId(1)).build(); diff --git a/core/src/test/java/com/backblaze/b2/client/contentHandlers/B2ContentWriterTest.java b/core/src/test/java/com/backblaze/b2/client/contentHandlers/B2ContentWriterTest.java index 3f1afcdb8..4182432df 100644 --- a/core/src/test/java/com/backblaze/b2/client/contentHandlers/B2ContentWriterTest.java +++ b/core/src/test/java/com/backblaze/b2/client/contentHandlers/B2ContentWriterTest.java @@ -185,6 +185,26 @@ public void testNoSha1ChecksPerformed() throws B2Exception { assertTrue(!sha1WasVerifiedFromDestination(writer)); // cuz range in headers } + @Test + public void testContentRangeResponseSkipsSha1Checks() throws B2Exception { + // a range response carries only part of the file, so the whole-file sha1 in the headers + // cannot match what we received. the writer must not check it. + final String wrongSha1 = B2Sha1.hexSha1OfBytes(makeBytes(LEN + 1)); + final B2Headers headers = B2HeadersImpl + .builder() + .set(B2Headers.CONTENT_LENGTH, "" + LEN) + .set(B2Headers.CONTENT_RANGE, "bytes 1-" + LEN + "/" + (LEN + 1)) + .set(B2Headers.CONTENT_SHA1, wrongSha1) + .build(); + + assertEquals(null, getSha1ToCheckOrNull(headers)); + + writer.readContent(headers, in); // must not throw a sha1 mismatch + + assertArrayEquals(makeBytes(LEN), writer.getBytes()); + assertTrue(!sha1WasVerifiedFromDestination(writer)); + } + @Test public void testBothSha1ChecksPass_withSmallFileSha1Header() throws B2Exception { writer.readContent(makeHeadersWithLenAndSha1(bytes.length, rightSha1), in); diff --git a/core/src/test/java/com/backblaze/b2/client/contentSources/B2HeadersImplTest.java b/core/src/test/java/com/backblaze/b2/client/contentSources/B2HeadersImplTest.java index e165f23bd..c9429a877 100644 --- a/core/src/test/java/com/backblaze/b2/client/contentSources/B2HeadersImplTest.java +++ b/core/src/test/java/com/backblaze/b2/client/contentSources/B2HeadersImplTest.java @@ -15,6 +15,7 @@ import java.util.Map; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -168,6 +169,10 @@ public void testGetContentSha1EvenIfUnverifiedOrNull_andActuallyUnverified() { assertEquals(SAMPLE_SHA1, headers.getContentSha1EvenIfUnverifiedOrNull()); assertNull(makeEmpty().getContentSha1OrNull()); + + // the strict getter keeps the prefix, so an unverified value can never equal a real sha1. + assertEquals("unverified:" + SAMPLE_SHA1, headers.getContentSha1OrNull()); + assertNotEquals(SAMPLE_SHA1, headers.getContentSha1OrNull()); } @Test diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationRuleTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationRuleTest.java index d0601bc13..7255a69b9 100644 --- a/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationRuleTest.java +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationRuleTest.java @@ -77,4 +77,31 @@ public void testToJsonAndBack() { assertEquals(defaultConfig, converted); assertEquals(jsonString, convertedJson); } + + @Test + public void testIsSuspendedWhenNotProvided() { + final TreeSet eventTypes = new TreeSet<>(); + eventTypes.add("b2:ObjectCreated:Upload"); + final B2EventNotificationRule rule = new B2EventNotificationRule( + "myRule", + eventTypes, + "", + new B2WebhookConfiguration("https://www.example.com", "3XDfkdQte2OgA78qCtSD17LAzpj6ay9H"), + true); + + // the 5-argument constructor leaves isSuspended unset; that must read as "not suspended", not blow up. + assertEquals(false, rule.isSuspended()); + } + + @Test + public void testCompareToWhenSuspensionFieldsNotProvided() { + final TreeSet eventTypes = new TreeSet<>(); + eventTypes.add("b2:ObjectCreated:Upload"); + final B2WebhookConfiguration target = + new B2WebhookConfiguration("https://www.example.com", "3XDfkdQte2OgA78qCtSD17LAzpj6ay9H"); + final B2EventNotificationRule a = new B2EventNotificationRule("myRule", eventTypes, "", target, true); + final B2EventNotificationRule b = new B2EventNotificationRule("myRule", eventTypes, "", target, true); + + assertEquals(0, a.compareTo(b)); + } } diff --git a/core/src/test/java/com/backblaze/b2/json/B2JsonTest.java b/core/src/test/java/com/backblaze/b2/json/B2JsonTest.java index 340733003..179551a39 100644 --- a/core/src/test/java/com/backblaze/b2/json/B2JsonTest.java +++ b/core/src/test/java/com/backblaze/b2/json/B2JsonTest.java @@ -110,6 +110,13 @@ public void testBigInteger() throws IOException, B2JsonException { checkDeserializeSerialize("-271828182845904523536028747135266249775724709369995957496696762772407663035354759457138217852516642742746", BigDecimal.class); } + @Test + public void testLargeBigInteger() throws IOException, B2JsonException { + // testBigInteger passes BigDecimal.class for its large values, so BigInteger itself never sees one. + checkDeserializeSerialize("31415926535897932384626433832795028841971693993751058209749445923078164062862089986280348253421170679821480865132823066470", BigInteger.class); + checkDeserializeSerialize("-271828182845904523536028747135266249775724709369995957496696762772407663035354759457138217852516642742746", BigInteger.class); + } + @Test public void testByte() throws IOException, B2JsonException { checkDeserializeSerialize("13", Byte.class); @@ -2157,7 +2164,7 @@ public void testSerializeSkipFieldNotInVersion() throws B2JsonException { // Version 6 is the last version where it's present { - final B2JsonOptions options = B2JsonOptions.builder().setVersion(4).build(); + final B2JsonOptions options = B2JsonOptions.builder().setVersion(6).build(); assertEquals( "{\n \"x\": 3\n}", b2Json.toJson(new VersionedContainer(3, 5), options) @@ -3327,4 +3334,62 @@ public void testClassWithBothB2JsonConstructorAndB2JsonTypeAnnotations() { final B2JsonException thrown = assertThrows(B2JsonException.class, () -> B2Json.get().toJson(obj, compactOptions)); assertEquals("com.backblaze.b2.json.B2JsonTest$ClassWithBothB2JsonConstructorAndB2JsonTypeAnnotations has both @B2Json.type and @B2Json.constructor annotations", thrown.getMessage()); } + + @Test + public void testSerializedNameFieldIsPopulated() throws B2JsonException { + // Container.equals ignores field d, so assert it directly, for both spellings of its serialized name. + final String json = "{\n \"@d\": \"goodbye\",\n \"a\": 41,\n \"b\": \"hello\"\n}"; + assertEquals("goodbye", b2Json.fromJson(json, Container.class).d); + final String alternateJson = "{ \"\\u0040d\": \"goodbye\", \"a\": 41 }"; + assertEquals("goodbye", b2Json.fromJson(alternateJson, Container.class).d); + assertEquals(json, b2Json.toJson(new Container(41, "hello", "goodbye"))); + } + + @Test + public void testLocalDateThatDoesNotExistIsAJsonException() { + // Passes the range checks (day 1..31) but February 31 does not exist. + assertThrows(B2JsonException.class, () -> b2Json.fromJson("\"2015-02-31\"", LocalDate.class)); + assertThrows(B2JsonException.class, () -> b2Json.fromJson("\"20150231\"", LocalDate.class)); + assertThrows(B2JsonException.class, () -> b2Json.fromJson("\"2015-04-31\"", LocalDate.class)); + } + + @Test + public void testLocalDateUrlParamThatDoesNotExistIsAJsonException() { + final B2JsonLocalDateHandler handler = new B2JsonLocalDateHandler(); + assertThrows(B2JsonException.class, () -> handler.deserializeUrlParam("2015-02-31")); + assertThrows(B2JsonException.class, () -> handler.deserializeUrlParam("20150231")); + } + + private static class NullableConcurrentMapHolder { + @B2Json.required + public final ConcurrentMap map; + + @B2Json.constructor(params = "map") + public NullableConcurrentMapHolder(ConcurrentMap map) { + this.map = map; + } + } + + @Test + public void testConcurrentMapWithNullValueIsAJsonException() { + // ConcurrentHashMap cannot hold nulls, so this must be reported as bad JSON, not as a NullPointerException. + assertThrows(B2JsonException.class, () -> + b2Json.fromJson("{ \"map\": { \"a\": null } }", NullableConcurrentMapHolder.class)); + } + + private static class EnumSetHolder { + @B2Json.required + public final java.util.EnumSet colors; + + @B2Json.constructor(params = "colors") + public EnumSetHolder(java.util.EnumSet colors) { + this.colors = colors; + } + } + + @Test + public void testEnumSetWithNullItemIsAJsonException() { + assertThrows(B2JsonException.class, () -> + b2Json.fromJson("{ \"colors\": [ \"RED\", null ] }", EnumSetHolder.class)); + } } diff --git a/core/src/test/java/com/backblaze/b2/util/B2InputStreamExcerptTest.java b/core/src/test/java/com/backblaze/b2/util/B2InputStreamExcerptTest.java index 80857a1c2..6790745d6 100644 --- a/core/src/test/java/com/backblaze/b2/util/B2InputStreamExcerptTest.java +++ b/core/src/test/java/com/backblaze/b2/util/B2InputStreamExcerptTest.java @@ -33,6 +33,29 @@ public void after() { assertArrayEquals(B2StringUtil.getUtf8Bytes(DIGITS_STR), DIGITS_BYTES); } + /** An InputStream whose skip() skips at most one byte at a time, as the InputStream contract allows. */ + private static class OneByteSkipper extends ByteArrayInputStream { + OneByteSkipper(byte[] bytes) { + super(bytes); + } + + @Override + public synchronized long skip(long n) { + return super.skip(Math.min(n, 1)); + } + } + + @Test + public void testSkipThatSkipsLessThanRequested() throws IOException { + // the excerpt is "23456". skip(3) on a stream that skips 1 byte at a time must leave "56". + final InputStream in = excerpt(new OneByteSkipper(DIGITS_BYTES), 2, 5); + + assertEquals(3, in.skip(3)); + assertEquals('5', in.read()); + assertEquals('6', in.read()); + assertEquals(EOF, in.read()); + } + @Test public void testNegativeStartThrowsInConstructor() { thrown.expect(IllegalArgumentException.class); diff --git a/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/B2WebApiHttpClientImpl.java b/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/B2WebApiHttpClientImpl.java index 1db45bd7f..1d9ee916d 100644 --- a/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/B2WebApiHttpClientImpl.java +++ b/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/B2WebApiHttpClientImpl.java @@ -57,7 +57,7 @@ private B2WebApiHttpClientImpl(HttpClientFactory clientFactory) { this.clientFactory = (clientFactory != null) ? clientFactory : HttpClientFactoryImpl.build(); - defaultRequestConfig = HttpClientFactoryImpl.builder().createRequestConfig(); + defaultRequestConfig = this.clientFactory.getRequestConfig(); } @SuppressWarnings("WeakerAccess") diff --git a/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactory.java b/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactory.java index 6967bb409..dc030943d 100644 --- a/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactory.java +++ b/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactory.java @@ -5,6 +5,7 @@ package com.backblaze.b2.client.webApiHttpClient; import com.backblaze.b2.client.exceptions.B2Exception; +import org.apache.http.client.config.RequestConfig; import org.apache.http.impl.client.CloseableHttpClient; public interface HttpClientFactory extends AutoCloseable { @@ -24,6 +25,15 @@ public interface HttpClientFactory extends AutoCloseable { */ CloseableHttpClient create() throws B2Exception; + /** + * @return the RequestConfig (timeouts, etc.) the clients from create() use by default. + * The SDK copies this for downloads, so custom factories should override it + * if they configure non-default timeouts. + */ + default RequestConfig getRequestConfig() { + return HttpClientFactoryImpl.builder().createRequestConfig(); + } + /** * Called to release resources, such as an HttpClientConnectionManager. */ diff --git a/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactoryImpl.java b/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactoryImpl.java index adae523c0..c1e3623b7 100644 --- a/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactoryImpl.java +++ b/httpclient/src/main/java/com/backblaze/b2/client/webApiHttpClient/HttpClientFactoryImpl.java @@ -70,6 +70,11 @@ public static Builder builder() { return new Builder(); } + @Override + public RequestConfig getRequestConfig() { + return requestConfig; + } + @Override public CloseableHttpClient create() throws B2Exception { return HttpClients.custom() diff --git a/httpclient/src/test/java/com/backblaze/b2/client/webApiHttpClient/B2WebApiHttpClientImplTest.java b/httpclient/src/test/java/com/backblaze/b2/client/webApiHttpClient/B2WebApiHttpClientImplTest.java index d40f6ff71..90ac9618e 100644 --- a/httpclient/src/test/java/com/backblaze/b2/client/webApiHttpClient/B2WebApiHttpClientImplTest.java +++ b/httpclient/src/test/java/com/backblaze/b2/client/webApiHttpClient/B2WebApiHttpClientImplTest.java @@ -118,6 +118,7 @@ private void doTestGetContentWithJsonException(Map requestHeader // call getContent and verify the returned exception response data try { b2WebApiClient.getContent(url, requestHeaderBuilder.build(), sink); + org.junit.Assert.fail("expected B2Exception"); } catch (B2Exception b2Exception) { assertEquals(INTERNAL_ERROR_STRUCTURE.status, b2Exception.getStatus()); assertEquals(INTERNAL_ERROR_STRUCTURE.code, b2Exception.getCode()); @@ -135,6 +136,7 @@ private void doTestGetContentWithTextException(Map requestHeader // call getContent and verify the returned exception response data try { b2WebApiClient.getContent(url, requestHeaderBuilder.build(), sink); + org.junit.Assert.fail("expected B2Exception"); } catch (B2Exception b2Exception) { assertEquals(B2InternalErrorException.class, b2Exception.getClass()); assertEquals(HttpStatus.SC_INTERNAL_SERVER_ERROR, b2Exception.getStatus()); diff --git a/httpclient/src/test/java/com/backblaze/b2/client/webApiHttpClient/B2WebApiHttpClientImplTimeoutTest.java b/httpclient/src/test/java/com/backblaze/b2/client/webApiHttpClient/B2WebApiHttpClientImplTimeoutTest.java new file mode 100644 index 000000000..a92c07815 --- /dev/null +++ b/httpclient/src/test/java/com/backblaze/b2/client/webApiHttpClient/B2WebApiHttpClientImplTimeoutTest.java @@ -0,0 +1,43 @@ +/* + * Copyright 2026, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.webApiHttpClient; + +import com.backblaze.b2.client.contentHandlers.B2ContentMemoryWriter; +import org.junit.Test; + +import java.net.ServerSocket; +import java.net.Socket; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class B2WebApiHttpClientImplTimeoutTest { + @Test + public void testGetContentHonorsConfiguredSocketTimeout() throws Exception { + try (ServerSocket ss = new ServerSocket(0)) { + Thread t = new Thread(() -> { + try (Socket s = ss.accept()) { + s.getOutputStream().write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nabc".getBytes()); + s.getOutputStream().flush(); + Thread.sleep(30000); + } catch (Exception ignore) { } + }); + t.setDaemon(true); + t.start(); + HttpClientFactoryImpl f = HttpClientFactoryImpl.builder() + .setSupportInsecureHttp(true).setSocketTimeoutSeconds(1).build(); + B2WebApiHttpClientImpl c = B2WebApiHttpClientImpl.builder().setHttpClientFactory(f).build(); + long start = System.nanoTime(); + try { + c.getContent("http://localhost:" + ss.getLocalPort() + "/x", null, B2ContentMemoryWriter.build()); + fail("expected timeout"); + } catch (Exception e) { + // expected + } + long ms = (System.nanoTime() - start) / 1_000_000; + assertTrue("took " + ms + "ms, configured socket timeout 1s", ms < 5000); + } + } +}