diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/SignatureInfo.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/SignatureInfo.java index 1554505cf..f26157d9d 100644 --- a/google-cloud-storage/src/main/java/com/google/cloud/storage/SignatureInfo.java +++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/SignatureInfo.java @@ -16,23 +16,21 @@ package com.google.cloud.storage; +import static com.google.cloud.storage.SignedUrlEncodingHelper.Rfc3986UriEncode; import static com.google.common.base.Preconditions.checkArgument; -import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; import com.google.common.hash.Hashing; -import com.google.common.net.UrlEscapers; import java.net.URI; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.TimeZone; +import java.util.TreeMap; /** * Signature Info holds payload components of the string that requires signing. @@ -46,12 +44,24 @@ public class SignatureInfo { public static final char COMPONENT_SEPARATOR = '\n'; public static final String GOOG4_RSA_SHA256 = "GOOG4-RSA-SHA256"; public static final String SCOPE = "/auto/storage/goog4_request"; + private static final List RESERVED_PARAMS_LOWER = + ImmutableList.of( + // V2: + "expires", + "googleaccessid", + // V4: + "x-goog-algorithm", + "x-goog-credential", + "x-goog-date", + "x-goog-expires", + "x-goog-signedheaders"); private final HttpMethod httpVerb; private final String contentMd5; private final String contentType; private final long expiration; private final Map canonicalizedExtensionHeaders; + private final Map queryParams; private final URI canonicalizedResource; private final Storage.SignUrlOption.SignatureVersion signatureVersion; private final String accountEmail; @@ -70,17 +80,16 @@ private SignatureInfo(Builder builder) { this.accountEmail = builder.accountEmail; this.timestamp = builder.timestamp; + ImmutableMap.Builder headerBuilder = + new ImmutableMap.Builder().putAll(builder.canonicalizedExtensionHeaders); // The "host" header only needs to be present and signed if using V4. if (Storage.SignUrlOption.SignatureVersion.V4.equals(signatureVersion) && (!builder.canonicalizedExtensionHeaders.containsKey("host"))) { - canonicalizedExtensionHeaders = - new ImmutableMap.Builder() - .putAll(builder.canonicalizedExtensionHeaders) - .put("host", "storage.googleapis.com") - .build(); - } else { - canonicalizedExtensionHeaders = builder.canonicalizedExtensionHeaders; + headerBuilder.put("host", "storage.googleapis.com"); } + canonicalizedExtensionHeaders = headerBuilder.build(); + + queryParams = ImmutableMap.copyOf(builder.queryParams); Date date = new Date(timestamp); @@ -123,7 +132,7 @@ private String constructV2UnsignedPayload() { payload.append(COMPONENT_SEPARATOR); payload.append(expiration).append(COMPONENT_SEPARATOR); - if (canonicalizedExtensionHeaders != null) { + if (canonicalizedExtensionHeaders.size() > 0) { payload.append( new CanonicalExtensionHeadersSerializer(Storage.SignUrlOption.SignatureVersion.V2) .serialize(canonicalizedExtensionHeaders)); @@ -167,49 +176,80 @@ private String constructV4CanonicalRequestHash() { .toString(); } - public String constructV4QueryString() { - ArrayListMultimap paramMap = ArrayListMultimap.create(); + /** + * Returns a TreeMap containing the user-supplied query parameters that do not have reserved keys. + */ + private TreeMap getNonReservedUserQueryParams() { + TreeMap sortedParamMap = new TreeMap(); + + // Skip any instances of well-known required headers that might have been supplied by the + // caller. + for (Map.Entry entry : queryParams.entrySet()) { + // Convert to (and check for the existence of) lowercase keys to prevent cases like a user + // supplying "x-goog-algorithm", in order to prevent the resulting query string from + // containing "x-goog-algorithm" and "X-Goog-Algorithm". + if (!RESERVED_PARAMS_LOWER.contains(entry.getKey().toLowerCase())) { + // URI encode user-supplied parameter, both the name and the value. + sortedParamMap.put( + Rfc3986UriEncode(entry.getKey(), true), Rfc3986UriEncode(entry.getValue(), true)); + } + } + + return sortedParamMap; + } + + private String queryStringFromParamMap(Map map) { + StringBuilder queryStringBuilder = new StringBuilder(); - // TODO: Once we support supplying additional query params, we should remove any of the reserved - // ones that users might have added. + String sep = ""; + for (Map.Entry entry : map.entrySet()) { + queryStringBuilder.append(sep); + sep = "&"; + queryStringBuilder.append(entry.getKey()).append('=').append(entry.getValue()); + } + + return queryStringBuilder.toString(); + } + + /** + * Returns a query string constructed from this object's stored query parameters, sorted in code + * point order. Note that these query parameters are not used when constructing the URL's + * signature. The returned value does not include the leading ? character, as this is not part of + * a query string. + * + * @return A URI query string. Returns an empty string if the user supplied no query parameters. + */ + public String constructV2QueryString() { + TreeMap sortedParamMap = getNonReservedUserQueryParams(); + // The "GoogleAccessId", "Expires", and "Signature" params are not included here. + return queryStringFromParamMap(sortedParamMap); + } + + /** + * Returns a query string constructed from this object's stored query parameters, sorted in code + * point order so that the query string can be used in a V4 canonical request string. The returned + * value does not include the leading ? character, as this is not part of a query string. + * + * @see + * Canonical Requests + */ + public String constructV4QueryString() { + TreeMap sortedParamMap = getNonReservedUserQueryParams(); // Add in the reserved auth-specific query params. - paramMap.put( - "X-Goog-Algorithm", UrlEscapers.urlFormParameterEscaper().escape(GOOG4_RSA_SHA256)); - paramMap.put( - "X-Goog-Credential", - UrlEscapers.urlFormParameterEscaper().escape(accountEmail + "/" + yearMonthDay + SCOPE)); - paramMap.put("X-Goog-Date", UrlEscapers.urlFormParameterEscaper().escape(exactDate)); - paramMap.put( - "X-Goog-Expires", UrlEscapers.urlFormParameterEscaper().escape(Long.toString(expiration))); + sortedParamMap.put("X-Goog-Algorithm", Rfc3986UriEncode(GOOG4_RSA_SHA256, true)); + sortedParamMap.put( + "X-Goog-Credential", Rfc3986UriEncode(accountEmail + "/" + yearMonthDay + SCOPE, true)); + sortedParamMap.put("X-Goog-Date", Rfc3986UriEncode(exactDate, true)); + sortedParamMap.put("X-Goog-Expires", Rfc3986UriEncode(Long.toString(expiration), true)); StringBuilder signedHeadersBuilder = new CanonicalExtensionHeadersSerializer(Storage.SignUrlOption.SignatureVersion.V4) .serializeHeaderNames(canonicalizedExtensionHeaders); - paramMap.put( - "X-Goog-SignedHeaders", - UrlEscapers.urlFormParameterEscaper().escape(signedHeadersBuilder.toString())); - - StringBuilder queryStringBuilder = new StringBuilder(); - ArrayList paramKeys = Lists.newArrayList(paramMap.keySet()); - Collections.sort(paramKeys); - for (String key : paramKeys) { - List valuesForCurrentKey = paramMap.get(key); - if (valuesForCurrentKey.size() > 1) { - // If there's more than 1 value for the given key, create a standalone list from the given - // view collection and sort it; params with multiple values must be sorted by value. - valuesForCurrentKey = Lists.newArrayList(valuesForCurrentKey); - Collections.sort(valuesForCurrentKey); - } - for (String value : valuesForCurrentKey) { - queryStringBuilder.append(key).append('=').append(value).append('&'); - } - } - // Remove trailing '&' from last-added param. - if (queryStringBuilder.length() > 0) { - queryStringBuilder.setLength(queryStringBuilder.length() - 1); - } + sortedParamMap.put( + "X-Goog-SignedHeaders", Rfc3986UriEncode(signedHeadersBuilder.toString(), true)); - return queryStringBuilder.toString(); + // The "X-Goog-Signature" param is not included here. + return queryStringFromParamMap(sortedParamMap); } public HttpMethod getHttpVerb() { @@ -232,6 +272,10 @@ public Map getCanonicalizedExtensionHeaders() { return canonicalizedExtensionHeaders; } + public Map getQueryParams() { + return queryParams; + } + public URI getCanonicalizedResource() { return canonicalizedResource; } @@ -255,6 +299,7 @@ public static final class Builder { private String contentType; private final long expiration; private Map canonicalizedExtensionHeaders; + private Map queryParams; private final URI canonicalizedResource; private Storage.SignUrlOption.SignatureVersion signatureVersion; private String accountEmail; @@ -280,6 +325,7 @@ public Builder(SignatureInfo signatureInfo) { this.contentType = signatureInfo.contentType; this.expiration = signatureInfo.expiration; this.canonicalizedExtensionHeaders = signatureInfo.canonicalizedExtensionHeaders; + this.queryParams = signatureInfo.queryParams; this.canonicalizedResource = signatureInfo.canonicalizedResource; this.signatureVersion = signatureInfo.signatureVersion; this.accountEmail = signatureInfo.accountEmail; @@ -305,6 +351,12 @@ public Builder setCanonicalizedExtensionHeaders( return this; } + public Builder setCanonicalizedQueryParams(Map queryParams) { + this.queryParams = queryParams; + + return this; + } + public Builder setSignatureVersion(Storage.SignUrlOption.SignatureVersion signatureVersion) { this.signatureVersion = signatureVersion; @@ -340,6 +392,10 @@ public SignatureInfo build() { canonicalizedExtensionHeaders = new HashMap<>(); } + if (queryParams == null) { + queryParams = new HashMap<>(); + } + return new SignatureInfo(this); } } diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java index 3091e0f69..77c88e97c 100644 --- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java +++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Storage.java @@ -1037,7 +1037,8 @@ enum Option { SIGNATURE_VERSION, HOST_NAME, PATH_STYLE, - VIRTUAL_HOSTED_STYLE + VIRTUAL_HOSTED_STYLE, + QUERY_PARAMS } enum SignatureVersion { @@ -1158,6 +1159,23 @@ public static SignUrlOption withVirtualHostedStyle() { public static SignUrlOption withPathStyle() { return new SignUrlOption(Option.PATH_STYLE, ""); } + + /** + * Use if the URL should contain additional query parameters. + * + *

Warning: For V2 Signed URLs, it is possible for query parameters to be altered after the + * URL has been signed, as the parameters are not used to compute the signature. The V4 signing + * method should be preferred when supplying additional query parameters, as the parameters + * cannot be added, removed, or otherwise altered after a V4 signature is generated. + * + * @see + * Canonical Requests + * @see V2 Signing + * Process + */ + public static SignUrlOption withQueryParams(Map queryParams) { + return new SignUrlOption(Option.QUERY_PARAMS, queryParams); + } } /** diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java index a8d144870..63c198557 100644 --- a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java +++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageImpl.java @@ -699,13 +699,22 @@ public URL signUrl(BlobInfo blobInfo, long duration, TimeUnit unit, SignUrlOptio if (isV4) { BaseEncoding encoding = BaseEncoding.base16().lowerCase(); String signature = URLEncoder.encode(encoding.encode(signatureBytes), UTF_8.name()); - stBuilder.append("?"); - stBuilder.append(signatureInfo.constructV4QueryString()); - stBuilder.append("&X-Goog-Signature=").append(signature); + String v4QueryString = signatureInfo.constructV4QueryString(); + + stBuilder.append('?'); + if (!Strings.isNullOrEmpty(v4QueryString)) { + stBuilder.append(v4QueryString).append('&'); + } + stBuilder.append("X-Goog-Signature=").append(signature); } else { BaseEncoding encoding = BaseEncoding.base64(); String signature = URLEncoder.encode(encoding.encode(signatureBytes), UTF_8.name()); - stBuilder.append("?"); + String v2QueryString = signatureInfo.constructV2QueryString(); + + stBuilder.append('?'); + if (!Strings.isNullOrEmpty(v2QueryString)) { + stBuilder.append(v2QueryString).append('&'); + } stBuilder.append("GoogleAccessId=").append(credentials.getAccount()); stBuilder.append("&Expires=").append(expiration); stBuilder.append("&Signature=").append(signature); @@ -815,7 +824,8 @@ private SignatureInfo buildSignatureInfo( signatureInfoBuilder.setTimestamp(getOptions().getClock().millisTime()); - ImmutableMap.Builder extHeaders = new ImmutableMap.Builder(); + ImmutableMap.Builder extHeadersBuilder = + new ImmutableMap.Builder(); boolean isV4 = SignUrlOption.SignatureVersion.V4.equals( @@ -823,20 +833,29 @@ private SignatureInfo buildSignatureInfo( if (isV4) { // We don't sign the host header for V2 signed URLs; only do this for V4. // Add the host here first, allowing it to be overridden in the EXT_HEADERS option below. if (optionMap.containsKey(SignUrlOption.Option.VIRTUAL_HOSTED_STYLE)) { - extHeaders.put( + extHeadersBuilder.put( "host", slashlessBucketNameFromBlobInfo(blobInfo) + "." + getBaseStorageHostName(optionMap)); } else if (optionMap.containsKey(SignUrlOption.Option.HOST_NAME)) { - extHeaders.put("host", getBaseStorageHostName(optionMap)); + extHeadersBuilder.put("host", getBaseStorageHostName(optionMap)); } } if (optionMap.containsKey(SignUrlOption.Option.EXT_HEADERS)) { - extHeaders.putAll((Map) optionMap.get(SignUrlOption.Option.EXT_HEADERS)); + extHeadersBuilder.putAll( + (Map) optionMap.get(SignUrlOption.Option.EXT_HEADERS)); + } + + ImmutableMap.Builder queryParamsBuilder = + new ImmutableMap.Builder(); + if (optionMap.containsKey(SignUrlOption.Option.QUERY_PARAMS)) { + queryParamsBuilder.putAll( + (Map) optionMap.get(SignUrlOption.Option.QUERY_PARAMS)); } return signatureInfoBuilder - .setCanonicalizedExtensionHeaders((Map) extHeaders.build()) + .setCanonicalizedExtensionHeaders((Map) extHeadersBuilder.build()) + .setCanonicalizedQueryParams((Map) queryParamsBuilder.build()) .build(); } diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java index 59139564a..6e698123c 100644 --- a/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java +++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageImplTest.java @@ -22,6 +22,7 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; @@ -79,6 +80,8 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.crypto.spec.SecretKeySpec; import org.easymock.Capture; import org.easymock.EasyMock; @@ -2316,6 +2319,132 @@ public void testSignUrlForBlobWithSlashesAndHostName() signer.verify(BaseEncoding.base64().decode(URLDecoder.decode(signature, UTF_8.name())))); } + @Test + public void testV2SignUrlWithQueryParams() + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, + UnsupportedEncodingException { + EasyMock.replay(storageRpcMock); + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(ACCOUNT) + .setPrivateKey(privateKey) + .build(); + storage = options.toBuilder().setCredentials(credentials).build().getService(); + + String dispositionNotEncoded = "attachment; filename=\"" + BLOB_NAME1 + "\""; + String dispositionEncoded = "attachment%3B%20filename%3D%22" + BLOB_NAME1 + "%22"; + URL url = + storage.signUrl( + BLOB_INFO1, + 14, + TimeUnit.DAYS, + Storage.SignUrlOption.withPathStyle(), + Storage.SignUrlOption.withV2Signature(), + Storage.SignUrlOption.withQueryParams( + ImmutableMap.of( + "response-content-disposition", dispositionNotEncoded))); + + String stringUrl = url.toString(); + + String expectedPrefix = + new StringBuilder("https://storage.googleapis.com/") + .append(BUCKET_NAME1) + .append('/') + .append(BLOB_NAME1) + // Query params aren't sorted for V2 signatures; user-supplied params are inserted at + // the start of the query string, before the required auth params. + .append("?response-content-disposition=") + .append(dispositionEncoded) + .append("&GoogleAccessId=") + .append(ACCOUNT) + .append("&Expires=") + .append(42L + 1209600) + .append("&Signature=") + .toString(); + assertTrue(stringUrl.startsWith(expectedPrefix)); + String signature = stringUrl.substring(expectedPrefix.length()); + + StringBuilder signedMessageBuilder = new StringBuilder(); + signedMessageBuilder + .append(HttpMethod.GET) + .append('\n') + // No value for Content-MD5, blank + .append('\n') + // No value for Content-Type, blank + .append('\n') + // Expiration line: + .append(42L + 1209600) + .append('\n') + // Resource line: + .append('/') + .append(BUCKET_NAME1) + .append('/') + .append(BLOB_NAME1); + + Signature signer = Signature.getInstance("SHA256withRSA"); + signer.initVerify(publicKey); + signer.update(signedMessageBuilder.toString().getBytes(UTF_8)); + assertTrue( + signer.verify(BaseEncoding.base64().decode(URLDecoder.decode(signature, UTF_8.name())))); + } + + // TODO(b/144304815): Remove this test once all conformance tests contain query param test cases. + @Test + public void testV4SignUrlWithQueryParams() { + EasyMock.replay(storageRpcMock); + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail(ACCOUNT) + .setPrivateKey(privateKey) + .build(); + storage = options.toBuilder().setCredentials(credentials).build().getService(); + + String dispositionNotEncoded = "attachment; filename=\"" + BLOB_NAME1 + "\""; + String dispositionEncoded = "attachment%3B%20filename%3D%22" + BLOB_NAME1 + "%22"; + URL url = + storage.signUrl( + BLOB_INFO1, + 6, + TimeUnit.DAYS, + Storage.SignUrlOption.withPathStyle(), + Storage.SignUrlOption.withV4Signature(), + Storage.SignUrlOption.withQueryParams( + ImmutableMap.of( + "response-content-disposition", dispositionNotEncoded))); + String stringUrl = url.toString(); + String expectedPrefix = + new StringBuilder("https://storage.googleapis.com/") + .append(BUCKET_NAME1) + .append('/') + .append(BLOB_NAME1) + .append('?') + .toString(); + assertTrue(stringUrl.startsWith(expectedPrefix)); + String restOfUrl = stringUrl.substring(expectedPrefix.length()); + + Pattern pattern = + Pattern.compile( + // We use the same code to construct the canonical request query string as we do to + // construct the query string used in the final URL, so this query string should also be + // sorted correctly, except for the trailing x-goog-signature param. + new StringBuilder("X-Goog-Algorithm=GOOG4-RSA-SHA256") + .append("&X-Goog-Credential=[^&]+") + .append("&X-Goog-Date=[^&]+") + .append("&X-Goog-Expires=[^&]+") + .append("&X-Goog-SignedHeaders=[^&]+") + .append("&response-content-disposition=[^&]+") + // Signature is always tacked onto the end of the final URL; it's not sorted w/ the + // other params above, since the signature is not known when you're constructing the + // query string line of the canonical request string. + .append("&X-Goog-Signature=.*") + .toString()); + Matcher matcher = pattern.matcher(restOfUrl); + assertTrue(restOfUrl, matcher.matches()); + + // Make sure query param was encoded properly. + assertNotEquals(-1, restOfUrl.indexOf("&response-content-disposition=" + dispositionEncoded)); + } + @Test public void testGetAllArray() { BlobId blobId1 = BlobId.of(BUCKET_NAME1, BLOB_NAME1); diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java index 829839cb6..e1788e6b6 100644 --- a/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java +++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageTest.java @@ -1844,6 +1844,73 @@ public void testGetSignedUrl() throws IOException { } } + @Test + public void testGetV2SignedUrlWithAddlQueryParam() throws IOException { + if (storage.getOptions().getCredentials() != null) { + assumeTrue(storage.getOptions().getCredentials() instanceof ServiceAccountSigner); + } + String blobName = "test-get-v2-with-generation-param"; + BlobInfo blob = BlobInfo.newBuilder(BUCKET, blobName).build(); + Blob remoteBlob = storage.create(blob, BLOB_BYTE_CONTENT); + assertNotNull(remoteBlob); + for (Storage.SignUrlOption urlStyle : + Arrays.asList( + Storage.SignUrlOption.withPathStyle(), + Storage.SignUrlOption.withVirtualHostedStyle())) { + String generationStr = remoteBlob.getGeneration().toString(); + URL url = + storage.signUrl( + blob, + 1, + TimeUnit.HOURS, + urlStyle, + Storage.SignUrlOption.withV2Signature(), + Storage.SignUrlOption.withQueryParams( + ImmutableMap.of("generation", generationStr))); + // Finally, verify that the URL works and we can get the object as expected: + URLConnection connection = url.openConnection(); + byte[] readBytes = new byte[BLOB_BYTE_CONTENT.length]; + try (InputStream responseStream = connection.getInputStream()) { + assertEquals(BLOB_BYTE_CONTENT.length, responseStream.read(readBytes)); + assertArrayEquals(BLOB_BYTE_CONTENT, readBytes); + } + } + } + + // TODO(b/144304815): Remove this test once all conformance tests contain query param test cases. + @Test + public void testGetV4SignedUrlWithAddlQueryParam() throws IOException { + if (storage.getOptions().getCredentials() != null) { + assumeTrue(storage.getOptions().getCredentials() instanceof ServiceAccountSigner); + } + String blobName = "test-get-v4-with-generation-param"; + BlobInfo blob = BlobInfo.newBuilder(BUCKET, blobName).build(); + Blob remoteBlob = storage.create(blob, BLOB_BYTE_CONTENT); + assertNotNull(remoteBlob); + for (Storage.SignUrlOption urlStyle : + Arrays.asList( + Storage.SignUrlOption.withPathStyle(), + Storage.SignUrlOption.withVirtualHostedStyle())) { + String generationStr = remoteBlob.getGeneration().toString(); + URL url = + storage.signUrl( + blob, + 1, + TimeUnit.HOURS, + urlStyle, + Storage.SignUrlOption.withV4Signature(), + Storage.SignUrlOption.withQueryParams( + ImmutableMap.of("generation", generationStr))); + // Finally, verify that the URL works and we can get the object as expected: + URLConnection connection = url.openConnection(); + byte[] readBytes = new byte[BLOB_BYTE_CONTENT.length]; + try (InputStream responseStream = connection.getInputStream()) { + assertEquals(BLOB_BYTE_CONTENT.length, responseStream.read(readBytes)); + assertArrayEquals(BLOB_BYTE_CONTENT, readBytes); + } + } + } + @Test public void testPostSignedUrl() throws IOException { if (storage.getOptions().getCredentials() != null) {