diff --git a/composer.json b/composer.json index c52377745456..b8a816c0e2d2 100644 --- a/composer.json +++ b/composer.json @@ -59,12 +59,14 @@ "vierbergenlars/php-semver": "^3.0", "google/proto-client": "^0.20.0", "google/gax": "^0.20.0", - "symfony/lock": "3.3.x-dev#1ba6ac9" + "symfony/lock": "3.3.x-dev#1ba6ac9", + "phpseclib/phpseclib": "^2.0" }, "suggest": { "google/gax": "Required to support gRPC", - "google/proto-client": "Required to support gRPC", - "symfony/lock": "Required for the Spanner cached based session pool. Please require the following commit: 3.3.x-dev#1ba6ac9" + "google/proto-client-php": "Required to support gRPC", + "symfony/lock": "Required for the Spanner cached based session pool. Please require the following commit: 3.3.x-dev#1ba6ac9", + "phpseclib/phpseclib": "May be used in place of OpenSSL for creating signed Cloud Storage URLs." }, "autoload": { "psr-4": { diff --git a/dev/src/Functions.php b/dev/src/Functions.php index fea2e9cf8d88..0f225265f71f 100644 --- a/dev/src/Functions.php +++ b/dev/src/Functions.php @@ -27,3 +27,22 @@ function stub($extends, array $args = [], array $props = []) $reflection = new \ReflectionClass($name); return $reflection->newInstanceArgs($args); } + +/** + * Get a trait implementation. + * + * @param string $trait The fully-qualified name of the trait to implement. + * @return mixed + */ +function impl($trait) +{ + $tpl = 'class %s { use %s; public function call($fn, array $args = []) { return call_user_func_array([$this, $fn], $args); } }'; + + $name = 'Trait'. sha1($trait); + + if (!class_exists($name)) { + eval(sprintf($tpl, $name, $trait)); + } + + return new $name; +} diff --git a/src/Core/GrpcTrait.php b/src/Core/GrpcTrait.php index 0ef7c6b03460..a3d28abf1332 100644 --- a/src/Core/GrpcTrait.php +++ b/src/Core/GrpcTrait.php @@ -50,6 +50,16 @@ public function setRequestWrapper(GrpcRequestWrapper $requestWrapper) $this->requestWrapper = $requestWrapper; } + /** + * Get the GrpcRequestWrapper. + * + * @return GrpcRequestWrapper|null + */ + public function requestWrapper() + { + return $this->requestWrapper; + } + /** * Delivers a request. * diff --git a/src/Core/RequestWrapperTrait.php b/src/Core/RequestWrapperTrait.php index c0141cf5d50c..287eb87a0534 100644 --- a/src/Core/RequestWrapperTrait.php +++ b/src/Core/RequestWrapperTrait.php @@ -117,6 +117,16 @@ public function setCommonDefaults(array $config) $this->requestTimeout = $config['requestTimeout']; } + /** + * Get the Keyfile. + * + * @return array + */ + public function keyFile() + { + return $this->keyFile; + } + /** * Gets the credentials fetcher and sets up caching. Precedence begins with * user supplied credentials fetcher instance, followed by a reference to a diff --git a/src/Core/RestTrait.php b/src/Core/RestTrait.php index 1640e5baad55..4d34c6f4f5c5 100644 --- a/src/Core/RestTrait.php +++ b/src/Core/RestTrait.php @@ -61,6 +61,16 @@ public function setRequestWrapper(RequestWrapper $requestWrapper) $this->requestWrapper = $requestWrapper; } + /** + * Get the RequestWrapper. + * + * @return RequestWrapper|null + */ + public function requestWrapper() + { + return $this->requestWrapper; + } + /** * Delivers a request built from the service definition. * diff --git a/src/Core/Upload/AbstractUploader.php b/src/Core/Upload/AbstractUploader.php index ca8ce62479ee..f3dccba3d1b2 100644 --- a/src/Core/Upload/AbstractUploader.php +++ b/src/Core/Upload/AbstractUploader.php @@ -31,6 +31,9 @@ abstract class AbstractUploader const UPLOAD_TYPE_RESUMABLE = 'resumable'; const UPLOAD_TYPE_MULTIPART = 'multipart'; + const UPLOAD_TYPE_STREAMABLE = 'streamable'; + const UPLOAD_TYPE_SIGNED = 'signed'; + const RESUMABLE_LIMIT = 5000000; /** diff --git a/src/Core/Upload/ResumableUploader.php b/src/Core/Upload/ResumableUploader.php index dd45c61af655..f26d70ee8e2c 100644 --- a/src/Core/Upload/ResumableUploader.php +++ b/src/Core/Upload/ResumableUploader.php @@ -32,7 +32,7 @@ class ResumableUploader extends AbstractUploader { use JsonTrait; - + /** * @var callable */ @@ -47,11 +47,8 @@ class ResumableUploader extends AbstractUploader * @var string */ private $resumeUri; - + /** - * Extend the parent constructor with the specific - * for resumable upload option "uploadProgressCallback" - * * @param RequestWrapper $requestWrapper * @param string|resource|StreamInterface $data * @param string $uri @@ -59,8 +56,14 @@ class ResumableUploader extends AbstractUploader * Optional configuration. * * @type array $metadata Metadata on the resource. - * @type callable $uploadProgressCallback to be called on each - * successfully uploaded chunk. + * @type callable $uploadProgressCallback The given callable + * function/method will be called after each successfully uploaded + * chunk. The callable function/method will receive the number of + * uploaded bytes after each uploaded chunk as a parameter to this + * callable. It's useful if you want to create a progress bar when + * using resumable upload type together with $chunkSize parameter. + * If $chunkSize is not set the callable function/method will be + * called only once after the successful file upload. * @type int $chunkSize Size of the chunks to send incrementally during * a resumable upload. Must be in multiples of 262144 bytes. * @type array $restOptions HTTP client specific configuration options. @@ -78,8 +81,10 @@ public function __construct( parent::__construct($requestWrapper, $data, $uri, $options); // Set uploadProgressCallback if it's passed as an option. - if (isset($options['uploadProgressCallback'])) { + if (isset($options['uploadProgressCallback']) && is_callable($options['uploadProgressCallback'])) { $this->uploadProgressCallback = $options['uploadProgressCallback']; + } elseif (isset($options['uploadProgressCallback'])) { + throw new \InvalidArgumentException('$options.uploadProgressCallback must be a callable.'); } } @@ -114,7 +119,7 @@ public function resume($resumeUri) $response = $this->getStatusResponse(); if ($response->getBody()->getSize() > 0) { - return $this->jsonDecode($response->getBody(), true); + return $this->decodeResponse($response); } $this->rangeStart = $this->getRangeStart($response->getHeaderLine('Range')); @@ -141,11 +146,11 @@ public function upload() $this->chunkSize ?: - 1, $rangeStart ); - + $currStreamLimitSize = $data->getSize(); - + $rangeEnd = $rangeStart + ($currStreamLimitSize - 1); - + $headers = [ 'Content-Length' => $currStreamLimitSize, 'Content-Type' => $this->contentType, @@ -167,7 +172,7 @@ public function upload() $ex->getCode() ); } - + if (is_callable($this->uploadProgressCallback)) { call_user_func($this->uploadProgressCallback, $currStreamLimitSize); } @@ -175,6 +180,17 @@ public function upload() $rangeStart = $this->getRangeStart($response->getHeaderLine('Range')); } while ($response->getStatusCode() === 308); + return $this->decodeResponse($response); + } + + /** + * Fetch and decode the response body + * + * @param ResponseInterface $response + * @return array + */ + protected function decodeResponse(ResponseInterface $response) + { return $this->jsonDecode($response->getBody(), true); } @@ -183,7 +199,7 @@ public function upload() * * @return string */ - private function createResumeUri() + protected function createResumeUri() { $headers = [ 'X-Upload-Content-Type' => $this->contentType, @@ -191,17 +207,18 @@ private function createResumeUri() 'Content-Type' => 'application/json' ]; + $body = $this->jsonEncode($this->metadata); + $request = new Request( 'POST', $this->uri, $headers, - $this->jsonEncode($this->metadata) + $body ); $response = $this->requestWrapper->send($request, $this->requestOptions); - $this->resumeUri = $response->getHeaderLine('Location'); - return $this->resumeUri; + return $this->resumeUri = $response->getHeaderLine('Location'); } /** @@ -209,7 +226,7 @@ private function createResumeUri() * * @return ResponseInterface */ - private function getStatusResponse() + protected function getStatusResponse() { $request = new Request( 'PUT', @@ -226,7 +243,7 @@ private function getStatusResponse() * @param string $rangeHeader * @return int */ - private function getRangeStart($rangeHeader) + protected function getRangeStart($rangeHeader) { if (!$rangeHeader) { return null; diff --git a/src/Core/Upload/SignedUrlUploader.php b/src/Core/Upload/SignedUrlUploader.php new file mode 100644 index 000000000000..cf5a6f236df2 --- /dev/null +++ b/src/Core/Upload/SignedUrlUploader.php @@ -0,0 +1,61 @@ + $this->contentType, + 'Content-Length' => 0, + 'x-goog-resumable' => 'start' + ]; + + $request = new Request( + 'POST', + $this->uri, + $headers + ); + + $response = $this->requestWrapper->send($request, $this->requestOptions); + return $this->resumeUri = $response->getHeaderLine('Location'); + } + + /** + * Decode the response body + * + * @param ReponseInterface $response + * @return string + */ + protected function decodeResponse(ResponseInterface $response) + { + return $response->getBody(); + } +} diff --git a/src/Storage/Bucket.php b/src/Storage/Bucket.php index 45c309e683d7..1dfb8ae3aeb7 100644 --- a/src/Storage/Bucket.php +++ b/src/Storage/Bucket.php @@ -309,6 +309,14 @@ public function upload($data, array $options = []) * from the `encryptionKey` on your behalf if not provided, but * for best performance it is recommended to pass in a cached * version of the already calculated SHA. + * @type callable $uploadProgressCallback The given callable + * function/method will be called after each successfully uploaded + * chunk. The callable function/method will receive the number of + * uploaded bytes after each uploaded chunk as a parameter to this + * callable. It's useful if you want to create a progress bar when + * using resumable upload type together with $chunkSize parameter. + * If $chunkSize is not set the callable function/method will be + * called only once after the successful file upload. * } * @return ResumableUploader * @throws \InvalidArgumentException diff --git a/src/Storage/EncryptionTrait.php b/src/Storage/EncryptionTrait.php index 719079336e30..6d64c1328e7c 100644 --- a/src/Storage/EncryptionTrait.php +++ b/src/Storage/EncryptionTrait.php @@ -18,6 +18,7 @@ namespace Google\Cloud\Storage; use InvalidArgumentException; +use phpseclib\Crypt\RSA; /** * Trait which provides helper methods for customer-supplied encryption. @@ -109,4 +110,35 @@ private function buildHeaders($key, $keySHA256, $useCopySourceHeaders) return []; } + + /** + * Sign a string using a given private key. + * + * @param string $privateKey The private key to use to sign the data. + * @param string $data The data to sign. + * @param bool $forceOpenssl If true, OpenSSL will be used regardless of + * whether phpseclib is available. **Defaults to** `false`. + * @return string The signature + */ + protected function signString($privateKey, $data, $forceOpenssl = false) + { + $signature = ''; + + if (class_exists(RSA::class) && !$forceOpenssl) { + $rsa = new RSA; + $rsa->loadKey($privateKey); + $rsa->setSignatureMode(RSA::SIGNATURE_PKCS1); + $rsa->setHash('sha256'); + + $signature = $rsa->sign($data); + } elseif (extension_loaded('openssl')) { + openssl_sign($data, $signature, $privateKey, 'sha256WithRSAEncryption'); + } else { + // @codeCoverageIgnoreStart + throw new \RuntimeException('OpenSSL is not installed.'); + } + // @codeCoverageIgnoreEnd + + return $signature; + } } diff --git a/src/Storage/StorageClient.php b/src/Storage/StorageClient.php index f7a97f0660e0..ae6e9490e048 100644 --- a/src/Storage/StorageClient.php +++ b/src/Storage/StorageClient.php @@ -21,9 +21,12 @@ use Google\Cloud\Core\ClientTrait; use Google\Cloud\Core\Iterator\ItemIterator; use Google\Cloud\Core\Iterator\PageIterator; +use Google\Cloud\Core\Timestamp; +use Google\Cloud\Core\Upload\SignedUrlUploader; use Google\Cloud\Storage\Connection\ConnectionInterface; use Google\Cloud\Storage\Connection\Rest; use Psr\Cache\CacheItemPoolInterface; +use Psr\Http\Message\StreamInterface; /** * Google Cloud Storage allows you to store and retrieve data on Google's @@ -272,4 +275,40 @@ public function unregisterStreamWrapper($protocol = null) { StreamWrapper::unregister($protocol); } + + /** + * Create an uploader to handle a Signed URL. + * + * Example: + * ``` + * $uploader = $storage->signedUrlUploader($uri, fopen('/path/to/myfile.doc', 'r')); + * ``` + * + * @param string $uri The URI to accept an upload request. + * @param string|resource|StreamInterface $data The data to be uploaded + * @param array $options [optional] Configuration Options. Refer to + * {@see Google\Cloud\Core\Upload\AbstractUploader::__construct()}. + * @return SignedUrlUploader + */ + public function signedUrlUploader($uri, $data, array $options = []) + { + return new SignedUrlUploader($this->connection->requestWrapper(), $data, $uri, $options); + } + + /** + * Create a Timestamp object. + * + * Example: + * ``` + * $timestamp = $storage->timestamp(new \DateTime('2003-02-05 11:15:02.421827Z')); + * ``` + * + * @param \DateTimeInterface $value The timestamp value. + * @param int $nanoSeconds [optional] The number of nanoseconds in the timestamp. + * @return Timestamp + */ + public function timestamp(\DateTimeInterface $timestamp, $nanoSeconds = null) + { + return new Timestamp($timestamp, $nanoSeconds); + } } diff --git a/src/Storage/StorageObject.php b/src/Storage/StorageObject.php index 750e6e1d93cb..d1594189ef10 100644 --- a/src/Storage/StorageObject.php +++ b/src/Storage/StorageObject.php @@ -19,6 +19,8 @@ use Google\Cloud\Core\ArrayTrait; use Google\Cloud\Core\Exception\NotFoundException; +use Google\Cloud\Core\Timestamp; +use Google\Cloud\Core\Upload\SignedUrlUploader; use Google\Cloud\Storage\Connection\ConnectionInterface; use GuzzleHttp\Psr7; use Psr\Http\Message\StreamInterface; @@ -42,6 +44,8 @@ class StorageObject use ArrayTrait; use EncryptionTrait; + const DEFAULT_DOWNLOAD_URL = 'https://storage.googleapis.com'; + /** * @var Acl ACL for the object. */ @@ -632,6 +636,315 @@ public function downloadAsStream(array $options = []) ); } + /** + * Create a Signed URL for this object. + * + * Example: + * ``` + * $url = $object->signedUrl(new Timestamp(new DateTime('tomorrow'))); + * ``` + * + * ``` + * // Create a signed URL allowing updates to the object. + * $url = $object->signedUrl(new Timestamp(new DateTime('tomorrow')), [ + * 'method' => 'PUT' + * ]); + * ``` + * + * @param Timestamp|\DateTimeInterface|int $expires Specifies when the URL + * will expire. May provide an instance of {@see Google\Cloud\Core\Timestamp}, + * [http://php.net/datetimeimmutable](`\DateTimeImmutable`), or a + * UNIX timestamp as an integer. + * @param array $options { + * Configuration Options. + * + * @type string $method One of `GET`, `PUT` or `DELETE`. + * **Defaults to** `GET`. + * @type string $cname The CNAME for the bucket, for instance + * `https://cdn.example.com`. **Defaults to** + * `https://storage.googleapis.com`. + * @type string $contentMd5 The MD5 digest value in base64. If you + * provide this, the client must provide this HTTP header with + * this same value in its request. If provided, take care to + * always provide this value as a base64 encoded string. + * @type string $contentType If you provide this value, the client must + * provide this HTTP header set to the same value. + * @type array $headers If these headers are used, the server will check + * to make sure that the client provides matching values. Provide + * headers as a key/value array, where the key is the header name, + * and the value is an array of header values. + * @type string $saveAsName The filename to prompt the user to save the + * file as when the signed url is accessed. This is ignored if + * `$options.responseDisposition` is set. + * @type string $responseDisposition The + * [`response-content-disposition`](http://www.iana.org/assignments/cont-disp/cont-disp.xhtml) + * parameter of the signed url. + * @type string $responseType The `response-content-type` parameter of the + * signed url. + * @type array $keyFile Keyfile data to use in place of the keyfile with + * which the client was constructed. If `$options.keyFilePath` is + * set, this option is ignored. + * @type string $keyFilePath A path to a valid Keyfile to use in place + * of the keyfile with which the client was constructed. + * @type bool $forceOpenssl If true, OpenSSL will be used regardless of + * whether phpseclib is available. **Defaults to** `false`. + * } + * @return string + * @throws \InvalidArgumentException If the given expiration is in the past. + * @throws \InvalidArgumentException If the given `$options.method` is not valid. + * @throws \InvalidArgumentException If the given `$options.keyFilePath` is not valid. + * @throws \InvalidArgumentException If the keyfile does not contain the required information. + */ + public function signedUrl($expires, array $options = []) + { + $options += [ + 'method' => 'GET', + 'cname' => self::DEFAULT_DOWNLOAD_URL, + 'contentMd5' => null, + 'contentType' => null, + 'headers' => [], + 'saveAsName' => null, + 'responseDisposition' => null, + 'responseType' => null, + 'keyFile' => null, + 'keyFilePath' => null, + 'allowPost' => false, + 'forceOpenssl' => false + ]; + + if ($expires instanceof Timestamp) { + $seconds = $expires->get()->format('U'); + } elseif ($expires instanceof \DateTimeInterface) { + $seconds = $expires->format('U'); + } elseif (is_numeric($expires)) { + $seconds = (int) $expires; + } else { + throw new \InvalidArgumentException('Invalid expiration.'); + } + + if ($seconds < time()) { + throw new \InvalidArgumentException('Expiration cannot be in the past.'); + } + + $allowedMethods = ['GET', 'PUT', 'POST', 'DELETE']; + $options['method'] = strtoupper($options['method']); + if (!in_array($options['method'], $allowedMethods)) { + throw new \InvalidArgumentException('$options.method must be one of `GET`, `PUT` or `DELETE`.'); + } + + if ($options['method'] === 'POST' && !$options['allowPost']) { + throw new \InvalidArgumentException( + 'Invalid method. To create an upload URI, use StorageObject::signedUploadUrl().' + ); + } + + if ($options['keyFilePath']) { + if (!file_exists($options['keyFilePath'])) { + throw new \InvalidArgumentException(sprintf( + 'Keyfile path %s does not exist.', + $options['keyFilePath'] + )); + } + + $keyFile = json_decode(file_get_contents($options['keyFilePath']), true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \InvalidArgumentException(sprintf( + 'Keyfile path %s does not contain valid json.', + $options['keyFilePath'] + )); + } + } elseif ($options['keyFile']) { + $keyFile = $options['keyFile']; + } else { + $requestWrapper = $this->connection->requestWrapper(); + $keyFile = $requestWrapper->keyFile(); + } + + if (!isset($keyFile['private_key']) || !isset($keyFile['client_email'])) { + throw new \RuntimeException( + 'Keyfile does not provide required information. ' . + 'Please ensure keyfile includes `private_key` and `client_email`.' + ); + } + + $headers = []; + foreach ($options['headers'] as $name => $value) { + $value = (is_array($value)) + ? implode(',', $value) + : $value; + + $headers[] = $name .':'. $value; + } + + if ($headers) { + $headers[] = ''; + } + + $resource = sprintf('/%s/%s', $this->identity['bucket'], $this->identity['object']); + $toSign = [ + $options['method'], + $options['contentMd5'], + $options['contentType'], + $seconds, + implode(PHP_EOL, $headers) . $resource, + ]; + + $string = implode(PHP_EOL, $toSign); + $signature = $this->signString($keyFile['private_key'], $string, $options['forceOpenssl']); + $encodedSignature = urlencode(base64_encode($signature)); + + $query = []; + $query[] = 'GoogleAccessId=' . $keyFile['client_email']; + $query[] = 'Expires=' . $seconds; + $query[] = 'Signature=' . $encodedSignature; + + if ($options['contentType']) { + $query[] = 'response-content-type=' . urlencode($options['contentType']); + } + + if ($options['responseDisposition']) { + $query[] = 'response-content-disposition=' . urlencode($options['responseDisposition']); + } elseif ($options['saveAsName']) { + $query[] = 'response-content-disposition=attachment;filename="' . urlencode($options['saveAsName']) . '"'; + } + + if ($options['responseType']) { + $query[] = 'response-content-type=' . urlencode($options['responseType']); + } + + if ($this->identity['generation']) { + $query[] = 'generation=' . $this->identity['generation']; + } + + $options['cname'] = trim($options['cname'], '/'); + return $options['cname'] . $resource . '?' . implode('&', $query); + } + + /** + * Create a Signed Upload URL for this object. + * + * This method differs from {@see Google\Cloud\Storage\StorageObject::signedUrl()} + * in that it allows you to initiate a new resumable upload session. This + * can be used to allow non-authenticated users to insert an object into a + * bucket. + * + * In order to upload data, a session URI must be + * obtained by sending an HTTP POST request to the URL returned from this + * method. See the [Cloud Storage Documentation](https://goo.gl/b1ZiZm) for + * more information. + * + * If you prefer to skip this initial step, you may find + * {@see Google\Cloud\Storage\StorageObject::beginSignedUploadSession()} to + * fit your needs. Note that `beginSignedUploadSession()` cannot be used + * with Google Cloud PHP's Signed URL Uploader, and does not support a + * configurable expiration date. + * + * Example: + * ``` + * $timestamp = new Timestamp(new \DateTime('tomorrow')); + * $url = $object->signedUploadUrl($timestamp); + * ``` + * + * @param Timestamp|\DateTimeInterface|int $expires Specifies when the URL + * will expire. May provide an instance of {@see Google\Cloud\Core\Timestamp}, + * [http://php.net/datetimeimmutable](`\DateTimeImmutable`), or a + * UNIX timestamp as an integer. + * @param array $options { + * Configuration Options. + * + * @type string $contentType If you provide this value, the client must + * provide this HTTP header set to the same value. + * @type string $contentMd5 The MD5 digest value in base64. If you + * provide this, the client must provide this HTTP header with + * this same value in its request. If provided, take care to + * always provide this value as a base64 encoded string. + * @type array $headers If these headers are used, the server will check + * to make sure that the client provides matching values. Provide + * headers as a key/value array, where the key is the header name, + * and the value is an array of header values. + * @type array $keyFile Keyfile data to use in place of the keyfile with + * which the client was constructed. If `$options.keyFilePath` is + * set, this option is ignored. + * @type string $keyFilePath A path to a valid Keyfile to use in place + * of the keyfile with which the client was constructed. + * @type bool $forceOpenssl If true, OpenSSL will be used regardless of + * whether phpseclib is available. **Defaults to** `false`. + * } + * @return string + */ + public function signedUploadUrl($expires, array $options = []) + { + $options += [ + 'headers' => [], + 'contentType' => null, + 'contentMd5' => null, + ]; + + unset( + $options['cname'], + $options['saveAsName'], + $options['responseDisposition'], + $options['responseType'] + ); + + $options['headers']['x-goog-resumable'] = ['start']; + + return $this->signedUrl($expires, [ + 'method' => 'POST', + 'allowPost' => true + ] + $options); + } + + /** + * Create a signed URL upload session. + * + * The returned URL differs from the return value of + * {@see Google\Cloud\Storage\StorageObject::signedUploadUrl()} in that it + * is ready to accept upload data immediately via an HTTP PUT request. + * Because an upload session is created by the client, the expiration date + * is not configurable. The URL generated by this method is valid for one + * week. + * + * Example: + * ``` + * $url = $object->beginSignedUploadSession(); + * ``` + * + * @see https://cloud.google.com/storage/docs/xml-api/resumable-upload#practices Resumable Upload Best Practices + * + * @param array $options { + * Configuration Options. + * + * @type string $contentType If you provide this value, the client must + * provide this HTTP header set to the same value. + * @type string $contentMd5 The MD5 digest value in base64. If you + * provide this, the client must provide this HTTP header with + * this same value in its request. If provided, take care to + * always provide this value as a base64 encoded string. + * @type array $headers If these headers are used, the server will check + * to make sure that the client provides matching values. Provide + * headers as a key/value array, where the key is the header name, + * and the value is an array of header values. + * @type array $keyFile Keyfile data to use in place of the keyfile with + * which the client was constructed. If `$options.keyFilePath` is + * set, this option is ignored. + * @type string $keyFilePath A path to a valid Keyfile to use in place + * of the keyfile with which the client was constructed. + * @type bool $forceOpenssl If true, OpenSSL will be used regardless of + * whether phpseclib is available. **Defaults to** `false`. + * } + * @return string + */ + public function beginSignedUploadSession(array $options = []) + { + $timestamp = new \DateTimeImmutable('+1 minute'); + $startUri = $this->signedUploadUrl($timestamp, $options); + + $uploader = new SignedUrlUploader($this->connection->requestWrapper(), '', $startUri); + + return $uploader->getResumeUri(); + } + /** * Retrieves the object's details. If no object data is cached a network * request will be made to retrieve it. diff --git a/tests/KeyPairGenerateTrait.php b/tests/KeyPairGenerateTrait.php new file mode 100644 index 000000000000..e32937f9bce8 --- /dev/null +++ b/tests/KeyPairGenerateTrait.php @@ -0,0 +1,44 @@ +setSignatureMode(RSA::SIGNATURE_PKCS1); + $rsa->setHash('sha256'); + + $key = $rsa->createKey(); + usleep(500); + return [$key['privatekey'], $key['publickey']]; + } + + private function verifySignature($privateKey, $input, $signature) + { + $verify = $this->signString($privateKey, $input); + + return urlencode(base64_encode($verify)) === $signature; + } +} diff --git a/tests/snippets/Storage/BucketTest.php b/tests/snippets/Storage/BucketTest.php index 18fe1d01319d..8de0fab0af30 100644 --- a/tests/snippets/Storage/BucketTest.php +++ b/tests/snippets/Storage/BucketTest.php @@ -25,7 +25,7 @@ use Google\Cloud\Dev\Snippet\SnippetTestCase; use Google\Cloud\Storage\Acl; use Google\Cloud\Storage\Bucket; -use Google\Cloud\Storage\Connection\ConnectionInterface; +use Google\Cloud\Storage\Connection\Rest; use Google\Cloud\Storage\ObjectIterator; use Google\Cloud\Storage\StorageObject; use Prophecy\Argument; @@ -42,7 +42,7 @@ class BucketTest extends SnippetTestCase public function setUp() { - $this->connection = $this->prophesize(ConnectionInterface::class); + $this->connection = $this->prophesize(Rest::class); $this->bucket = \Google\Cloud\Dev\stub(Bucket::class, [ $this->connection->reveal(), self::BUCKET diff --git a/tests/snippets/Storage/StorageClientTest.php b/tests/snippets/Storage/StorageClientTest.php index 0f5d98583a69..933f80d60f51 100644 --- a/tests/snippets/Storage/StorageClientTest.php +++ b/tests/snippets/Storage/StorageClientTest.php @@ -17,11 +17,14 @@ namespace Google\Cloud\Tests\Snippets\Storage; +use Google\Cloud\Core\Iterator\ItemIterator; +use Google\Cloud\Core\RequestWrapper; +use Google\Cloud\Core\Timestamp; +use Google\Cloud\Core\Upload\SignedUrlUploader; use Google\Cloud\Dev\Snippet\SnippetTestCase; use Google\Cloud\Storage\Bucket; -use Google\Cloud\Storage\Connection\ConnectionInterface; +use Google\Cloud\Storage\Connection\Rest; use Google\Cloud\Storage\StorageClient; -use Google\Cloud\Core\Iterator\ItemIterator; use Prophecy\Argument; /** @@ -36,7 +39,7 @@ class StorageClientTest extends SnippetTestCase public function setUp() { - $this->connection = $this->prophesize(ConnectionInterface::class); + $this->connection = $this->prophesize(Rest::class); $this->client = \Google\Cloud\Dev\stub(StorageClient::class); $this->client->___setProperty('connection', $this->connection->reveal()); } @@ -132,4 +135,27 @@ public function testCreateBucketWithLogging() $res = $snippet->invoke('bucket'); $this->assertInstanceOf(Bucket::class, $res->returnVal()); } + + public function testSignedUrlUploader() + { + $rw = $this->prophesize(RequestWrapper::class); + $this->connection->requestWrapper()->willReturn($rw->reveal()); + + $snippet = $this->snippetFromMethod(StorageClient::class, 'signedUrlUploader'); + $snippet->addLocal('storage', $this->client); + $snippet->addLocal('uri', 'test'); + $snippet->replace('/path/to/myfile.doc', 'php://temp'); + + $res = $snippet->invoke('uploader'); + $this->assertInstanceOf(SignedUrlUploader::class, $res->returnVal()); + } + + public function testTimestamp() + { + $snippet = $this->snippetFromMethod(StorageClient::class, 'timestamp'); + $snippet->addLocal('storage', $this->client); + + $res = $snippet->invoke('timestamp'); + $this->assertInstanceOf(Timestamp::class, $res->returnVal()); } +} diff --git a/tests/snippets/Storage/StorageObjectTest.php b/tests/snippets/Storage/StorageObjectTest.php index 475ee81e9baa..c14c9b5ef2f8 100644 --- a/tests/snippets/Storage/StorageObjectTest.php +++ b/tests/snippets/Storage/StorageObjectTest.php @@ -17,13 +17,18 @@ namespace Google\Cloud\Tests\Snippets\Storage; +use Google\Cloud\Core\RequestWrapper; +use Google\Cloud\Core\Timestamp; use Google\Cloud\Dev\Snippet\SnippetTestCase; use Google\Cloud\Storage\Acl; use Google\Cloud\Storage\Bucket; -use Google\Cloud\Storage\Connection\ConnectionInterface; +use Google\Cloud\Storage\Connection\Rest; use Google\Cloud\Storage\StorageClient; use Google\Cloud\Storage\StorageObject; +use Google\Cloud\Tests\KeyPairGenerateTrait; +use GuzzleHttp\Psr7\Response; use Prophecy\Argument; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\StreamInterface; /** @@ -31,6 +36,8 @@ */ class StorageObjectTest extends SnippetTestCase { + use KeyPairGenerateTrait; + const OBJECT = 'my-object'; const BUCKET = 'my-bucket'; @@ -39,7 +46,7 @@ class StorageObjectTest extends SnippetTestCase public function setUp() { - $this->connection = $this->prophesize(ConnectionInterface::class); + $this->connection = $this->prophesize(Rest::class); $this->object = \Google\Cloud\Dev\stub(StorageObject::class, [ $this->connection->reveal(), self::OBJECT, @@ -359,4 +366,112 @@ public function testGcsUri() $expectedOutput = sprintf('gs://%s/%s', self::BUCKET, self::OBJECT); $this->assertEquals($expectedOutput, $res->output()); } + + public function testSignedUrl() + { + $snippet = $this->snippetFromMethod(StorageObject::class, 'signedUrl'); + $snippet->addLocal('object', $this->object); + $snippet->addUse(Timestamp::class); + + list($pkey, $pub) = $this->getKeyPair(); + $kf = [ + 'private_key' => $pkey, + 'client_email' => 'test@example.com' + ]; + + $rw = $this->prophesize(RequestWrapper::class); + $rw->keyFile()->willReturn($kf); + + $conn = $this->prophesize(Rest::class); + $conn->requestWrapper()->willReturn($rw->reveal()); + + $this->object->___setProperty('connection', $conn->reveal()); + + $res = $snippet->invoke('url'); + $this->assertTrue(strpos($res->returnVal(), 'https://storage.googleapis.com/my-bucket/my-object') !== false); + $this->assertTrue(strpos($res->returnVal(), 'Expires=') !== false); + $this->assertTrue(strpos($res->returnVal(), 'Signature=') !== false); + } + + public function testSignedUrlUpdate() + { + $snippet = $this->snippetFromMethod(StorageObject::class, 'signedUrl', 1); + $snippet->addLocal('object', $this->object); + $snippet->addUse(Timestamp::class); + + list($pkey, $pub) = $this->getKeyPair(); + $kf = [ + 'private_key' => $pkey, + 'client_email' => 'test@example.com' + ]; + + $rw = $this->prophesize(RequestWrapper::class); + $rw->keyFile()->willReturn($kf); + + $conn = $this->prophesize(Rest::class); + $conn->requestWrapper()->willReturn($rw->reveal()); + + $this->object->___setProperty('connection', $conn->reveal()); + + $res = $snippet->invoke('url'); + $this->assertTrue(strpos($res->returnVal(), 'https://storage.googleapis.com/my-bucket/my-object') !== false); + $this->assertTrue(strpos($res->returnVal(), 'Expires=') !== false); + $this->assertTrue(strpos($res->returnVal(), 'Signature=') !== false); + } + + public function testSignedUploadUrl() + { + $snippet = $this->snippetFromMethod(StorageObject::class, 'signedUploadUrl'); + $snippet->addLocal('object', $this->object); + $snippet->addUse(Timestamp::class); + + list($pkey, $pub) = $this->getKeyPair(); + $kf = [ + 'private_key' => $pkey, + 'client_email' => 'test@example.com' + ]; + + $rw = $this->prophesize(RequestWrapper::class); + $rw->keyFile()->willReturn($kf); + + $conn = $this->prophesize(Rest::class); + $conn->requestWrapper()->willReturn($rw->reveal()); + + $this->object->___setProperty('connection', $conn->reveal()); + + $res = $snippet->invoke('url'); + $this->assertTrue(strpos($res->returnVal(), 'https://storage.googleapis.com/my-bucket/my-object') !== false); + $this->assertTrue(strpos($res->returnVal(), 'Expires=') !== false); + $this->assertTrue(strpos($res->returnVal(), 'Signature=') !== false); + } + + public function testBeginSignedUploadSession() + { + $snippet = $this->snippetFromMethod(StorageObject::class, 'beginSignedUploadSession'); + $snippet->addLocal('object', $this->object); + $snippet->addUse(Timestamp::class); + + list($pkey, $pub) = $this->getKeyPair(); + $kf = [ + 'private_key' => $pkey, + 'client_email' => 'test@example.com' + ]; + + $rw = $this->prophesize(RequestWrapper::class); + $rw->keyFile()->willReturn($kf); + + $resumeUri = 'theResumeUri'; + $response = new Response(200, ['Location' => $resumeUri]); + + $rw->send( + Argument::type(RequestInterface::class), + Argument::type('array') + )->willReturn($response); + + $this->connection->requestWrapper()->willReturn($rw->reveal()); + $this->object->___setProperty('connection', $this->connection->reveal()); + + $res = $snippet->invoke('url'); + $this->assertEquals($resumeUri, $res->returnVal()); + } } diff --git a/tests/system/Storage/SignedUrlTest.php b/tests/system/Storage/SignedUrlTest.php new file mode 100644 index 000000000000..bf914343cf32 --- /dev/null +++ b/tests/system/Storage/SignedUrlTest.php @@ -0,0 +1,94 @@ +guzzle = new Client; + } + + public function testSignedUrl() + { + $obj = $this->createFile(); + self::$deletionQueue[] = $obj; + + $ts = new Timestamp(new \DateTime('tomorrow')); + $url = $obj->signedUrl($ts); + + $this->assertEquals(self::CONTENT, $this->getFile($url)); + } + + /** + * @expectedException Google\Cloud\Core\Exception\NotFoundException + */ + public function testSignedUrlDelete() + { + $obj = $this->createFile(); + self::$deletionQueue[] = $obj; + + $ts = new Timestamp(new \DateTime('tomorrow')); + $url = $obj->signedUrl($ts, [ + 'method' => 'DELETE', + 'contentType' => 'text/plain' + ]); + + $this->deleteFile($url, [ + 'Content-type' => 'text/plain' + ]); + + $obj->reload(); + } + + private function createFile() + { + $bucket = self::$bucket; + $object = $bucket->upload(self::CONTENT, [ + 'name' => uniqid(self::TESTING_PREFIX) .'.txt', + ]); + + return $object; + } + + private function getFile($url) + { + $res = $this->guzzle->request('GET', $url); + + return (string) $res->getBody(); + } + + private function deleteFile($url, array $headers = []) + { + + $this->guzzle->request('DELETE', $url, [ + 'headers' => $headers + ]); + } +} diff --git a/tests/unit/Core/GrpcRequestWrapperTest.php b/tests/unit/Core/GrpcRequestWrapperTest.php index d1636a485a93..08969590bb29 100644 --- a/tests/unit/Core/GrpcRequestWrapperTest.php +++ b/tests/unit/Core/GrpcRequestWrapperTest.php @@ -41,6 +41,17 @@ public function setUp() $this->checkAndSkipGrpcTests(); } + public function testGetKeyfile() + { + $kf = 'hello world'; + + $requestWrapper = new GrpcRequestWrapper([ + 'keyFile' => $kf + ]); + + $this->assertEquals($kf, $requestWrapper->keyFile()); + } + /** * @dataProvider responseProvider */ @@ -180,10 +191,13 @@ public function exceptionProvider() return [ [3, Exception\BadRequestException::class], [5, Exception\NotFoundException::class], + [12, Exception\NotFoundException::class], [6, Exception\ConflictException::class], + [9, Exception\FailedPreconditionException::class], [2, Exception\ServerException::class], [13, Exception\ServerException::class], - [15, Exception\ServiceException::class] + [10, Exception\AbortedException::class], + [999, Exception\ServiceException::class] ]; } } diff --git a/tests/unit/Core/GrpcTraitTest.php b/tests/unit/Core/GrpcTraitTest.php index 87068d6ccdf0..c95d5329ce9d 100644 --- a/tests/unit/Core/GrpcTraitTest.php +++ b/tests/unit/Core/GrpcTraitTest.php @@ -20,9 +20,11 @@ use Google\Auth\Cache\MemoryCacheItemPool; use Google\Auth\FetchAuthTokenCache; use Google\Auth\FetchAuthTokenInterface; +use Google\Cloud\Core\Exception\NotFoundException; use Google\Cloud\Tests\GrpcTestTrait; use Google\Cloud\Core\GrpcRequestWrapper; use Google\Cloud\Core\GrpcTrait; +use google\protobuf; use Prophecy\Argument; /** @@ -39,10 +41,16 @@ public function setUp() { $this->checkAndSkipGrpcTests(); - $this->implementation = new GrpcTraitStub(); + $this->implementation = \Google\Cloud\Dev\impl(GrpcTrait::class); $this->requestWrapper = $this->prophesize(GrpcRequestWrapper::class); } + public function testSetGetRequestWrapper() + { + $this->implementation->setRequestWrapper($this->requestWrapper->reveal()); + $this->assertInstanceOf(GrpcRequestWrapper::class, $this->implementation->requestWrapper()); + } + public function testSendsRequest() { $grpcOptions = [ @@ -85,6 +93,52 @@ public function testSendsRequestWithOptions() $this->assertEquals($message, $actualResponse); } + public function testSendsRequestNotFoundWhitelisted() + { + $grpcOptions = [ + 'timeoutMs' => 100 + ]; + $this->requestWrapper->send( + Argument::type('callable'), + Argument::type('array'), + ['grpcOptions' => $grpcOptions] + )->willThrow(new NotFoundException('uh oh')); + + $this->implementation->setRequestWrapper($this->requestWrapper->reveal()); + + $msg = null; + try { + $this->implementation->send(function () {}, [['grpcOptions' => $grpcOptions]], true); + } catch (NotFoundException $e) { + $msg = $e->getMessage(); + } + + $this->assertFalse(strpos($msg, 'NOTE: Error may be due to Whitelist Restriction.') === false); + } + + public function testSendsRequestNotFoundNotWhitelisted() + { + $grpcOptions = [ + 'timeoutMs' => 100 + ]; + $this->requestWrapper->send( + Argument::type('callable'), + Argument::type('array'), + ['grpcOptions' => $grpcOptions] + )->willThrow(new NotFoundException('uh oh')); + + $this->implementation->setRequestWrapper($this->requestWrapper->reveal()); + + $msg = null; + try { + $this->implementation->send(function () {}, [['grpcOptions' => $grpcOptions]], false); + } catch (NotFoundException $e) { + $msg = $e->getMessage(); + } + + $this->assertTrue(strpos($msg, 'NOTE: Error may be due to Whitelist Restriction.') === false); + } + public function testGetsGaxConfig() { $version = '1.0.0'; @@ -212,13 +266,3 @@ public function valueProvider() ]; } } - -class GrpcTraitStub -{ - use GrpcTrait; - - public function call($fn, array $args = []) - { - return call_user_func_array([$this, $fn], $args); - } -} diff --git a/tests/unit/Core/JsonTraitTest.php b/tests/unit/Core/JsonTraitTest.php index 19e169658db7..5f58df066aef 100644 --- a/tests/unit/Core/JsonTraitTest.php +++ b/tests/unit/Core/JsonTraitTest.php @@ -20,7 +20,7 @@ use Google\Cloud\Core\JsonTrait; /** - * @group root + * @group core */ class JsonTraitTest extends \PHPUnit_Framework_TestCase { @@ -28,7 +28,7 @@ class JsonTraitTest extends \PHPUnit_Framework_TestCase public function setUp() { - $this->implementation = new JsonTraitStub(); + $this->implementation = \Google\Cloud\Dev\impl(JsonTrait::class); } public function testJsonEncode() @@ -57,13 +57,3 @@ public function testJsonDecodeThrowsException() $this->implementation->call('jsonDecode', ['.|.']); } } - -class JsonTraitStub -{ - use JsonTrait; - - public function call($fn, array $args) - { - return call_user_func_array([$this, $fn], $args); - } -} diff --git a/tests/unit/Core/RequestWrapperTest.php b/tests/unit/Core/RequestWrapperTest.php index 517e5aa8a36f..61b3c28b6e2e 100644 --- a/tests/unit/Core/RequestWrapperTest.php +++ b/tests/unit/Core/RequestWrapperTest.php @@ -59,6 +59,17 @@ public function testSuccessfullySendsRequest() $this->assertEquals($expectedBody, (string) $actualResponse->getBody()); } + public function testGetKeyfile() + { + $kf = 'hello world'; + + $requestWrapper = new RequestWrapper([ + 'keyFile' => $kf + ]); + + $this->assertEquals($kf, $requestWrapper->keyFile()); + } + /** * @expectedException Google\Cloud\Core\Exception\GoogleException */ diff --git a/tests/unit/Core/RestTraitTest.php b/tests/unit/Core/RestTraitTest.php index b40db24868d7..d1820cd5d3c9 100644 --- a/tests/unit/Core/RestTraitTest.php +++ b/tests/unit/Core/RestTraitTest.php @@ -17,12 +17,14 @@ namespace Google\Cloud\Tests\Unit\Core; +use Google\Cloud\Core\Exception\NotFoundException; use Google\Cloud\Core\RequestBuilder; use Google\Cloud\Core\RequestWrapper; use Google\Cloud\Core\RestTrait; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use Prophecy\Argument; +use Psr\Http\Message\RequestInterface; /** * @group core @@ -42,6 +44,12 @@ public function setUp() ->willReturn(new Request('GET', '/someplace')); } + public function testSetGetRequestWrapper() + { + $this->implementation->setRequestWrapper($this->requestWrapper->reveal()); + $this->assertInstanceOf(RequestWrapper::class, $this->implementation->requestWrapper()); + } + public function testSendsRequest() { $responseBody = '{"whatAWonderful": "response"}'; @@ -72,4 +80,44 @@ public function testSendsRequestWithOptions() $this->assertEquals(json_decode($responseBody, true), $actualResponse); } + + public function testSendsRequestNotFoundWhitelisted() + { + $this->requestWrapper->send( + Argument::type(RequestInterface::class), + Argument::type('array') + )->willThrow(new NotFoundException('uh oh')); + + $this->implementation->setRequestBuilder($this->requestBuilder->reveal()); + $this->implementation->setRequestWrapper($this->requestWrapper->reveal()); + + $msg = null; + try { + $this->implementation->send('foo', 'bar', [], true); + } catch (NotFoundException $e) { + $msg = $e->getMessage(); + } + + $this->assertFalse(strpos($msg, 'NOTE: Error may be due to Whitelist Restriction.') === false); + } + + public function testSendsRequestNotFoundNotWhitelisted() + { + $this->requestWrapper->send( + Argument::type(RequestInterface::class), + Argument::type('array') + )->willThrow(new NotFoundException('uh oh')); + + $this->implementation->setRequestBuilder($this->requestBuilder->reveal()); + $this->implementation->setRequestWrapper($this->requestWrapper->reveal()); + + $msg = null; + try { + $this->implementation->send('foo', 'bar', [], false); + } catch (NotFoundException $e) { + $msg = $e->getMessage(); + } + + $this->assertTrue(strpos($msg, 'NOTE: Error may be due to Whitelist Restriction.') === false); + } } diff --git a/tests/unit/Core/Upload/ResumableUploaderTest.php b/tests/unit/Core/Upload/ResumableUploaderTest.php index b9f487935fd9..b8315c1bd50e 100644 --- a/tests/unit/Core/Upload/ResumableUploaderTest.php +++ b/tests/unit/Core/Upload/ResumableUploaderTest.php @@ -62,6 +62,46 @@ public function testUploadsData() $this->assertEquals(json_decode($this->successBody, true), $uploader->upload()); } + public function testUploadsDataWithCallback() + { + $response = new Response(200, ['Location' => 'theResumeUri'], $this->successBody); + + $called = false; + $callback = function() use (&$called) { + $called = true; + }; + + $this->requestWrapper->send( + Argument::type(RequestInterface::class), + Argument::type('array') + )->willReturn($response); + + $uploader = new ResumableUploader( + $this->requestWrapper->reveal(), + $this->stream, + 'http://www.example.com', + ['uploadProgressCallback' => $callback] + ); + + $this->assertEquals(json_decode($this->successBody, true), $uploader->upload()); + $this->assertTrue($called); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testUploadsDataWithInvalidCallback() + { + $callback = 'foo'; + + $uploader = new ResumableUploader( + $this->requestWrapper->reveal(), + $this->stream, + 'http://www.example.com', + ['uploadProgressCallback' => $callback] + ); + } + public function testGetResumeUri() { $resumeUri = 'theResumeUri'; diff --git a/tests/unit/Core/Upload/SignedUrlUploaderTest.php b/tests/unit/Core/Upload/SignedUrlUploaderTest.php new file mode 100644 index 000000000000..360531786b15 --- /dev/null +++ b/tests/unit/Core/Upload/SignedUrlUploaderTest.php @@ -0,0 +1,71 @@ +requestWrapper = $this->prophesize(RequestWrapper::class); + $this->stream = Psr7\stream_for('abcd'); + $this->successBody = '{"canI":"kickIt"}'; + } + + public function testGetResumeUri() + { + $resumeUri = 'theResumeUri'; + $response = new Response(200, ['Location' => $resumeUri]); + + $this->requestWrapper->send( + Argument::that(function ($arg) { + if (!($arg instanceof RequestInterface)) return false; + if ($arg->getHeaderLine('Content-Type') !== 'application/octet-stream') return false; + if ($arg->getHeaderLine('Content-Length') != 0) return false; + if ($arg->getHeaderLine('x-goog-resumable') !== 'start') return false; + return true; + }), + Argument::type('array') + )->willReturn($response); + + $uploader = new SignedUrlUploader( + $this->requestWrapper->reveal(), + $this->stream, + 'http://www.example.com' + ); + + $this->assertEquals($resumeUri, $uploader->getResumeUri()); + } +} diff --git a/tests/unit/Storage/AclTest.php b/tests/unit/Storage/AclTest.php index 06b3f0c8626b..df6b311e5363 100644 --- a/tests/unit/Storage/AclTest.php +++ b/tests/unit/Storage/AclTest.php @@ -18,6 +18,7 @@ namespace Google\Cloud\Tests\Unit\Storage; use Google\Cloud\Storage\Acl; +use Google\Cloud\Storage\Connection\ConnectionInterface; use Prophecy\Argument; /** @@ -29,7 +30,7 @@ class AclTest extends \PHPUnit_Framework_TestCase public function setUp() { - $this->connection = $this->prophesize('Google\Cloud\Storage\Connection\ConnectionInterface'); + $this->connection = $this->prophesize(ConnectionInterface::class); } /** diff --git a/tests/unit/Storage/EncryptionTraitTest.php b/tests/unit/Storage/EncryptionTraitTest.php index db09f49355f8..2fd145c5c65e 100644 --- a/tests/unit/Storage/EncryptionTraitTest.php +++ b/tests/unit/Storage/EncryptionTraitTest.php @@ -17,6 +17,7 @@ namespace Google\Cloud\Tests\Unit\Storage; +use Google\Cloud\Tests\KeyPairGenerateTrait; use Google\Cloud\Storage\EncryptionTrait; /** @@ -24,11 +25,35 @@ */ class EncryptionTraitTest extends \PHPUnit_Framework_TestCase { - private $trait; + use KeyPairGenerateTrait; + + private $implementation; public function setUp() { - $this->trait = $this->getObjectForTrait(EncryptionTrait::class); + $this->implementation = \Google\Cloud\Dev\impl(EncryptionTrait::class); + } + + public function testSignString() + { + $testString = 'hello world'; + + list($pkey, $pub) = $this->getKeyPair(); + + $res = $this->implementation->call('signString', [$pkey, $testString]); + + $this->assertTrue($this->verifySignature($pkey, $testString, urlencode(base64_encode($res)))); + } + + public function testSignStringWithOpenSsl() + { + $testString = 'hello world'; + + list($pkey, $pub) = $this->getKeyPair(); + + $res = $this->implementation->call('signString', [$pkey, $testString, true]); + + $this->assertTrue($this->verifySignature($pkey, $testString, urlencode(base64_encode($res)))); } /** @@ -38,7 +63,7 @@ public function testFormatEncryptionHeaders($expectedOptions, $options) { $this->assertEquals( $expectedOptions, - $this->trait->formatEncryptionHeaders($options) + $this->implementation->formatEncryptionHeaders($options) ); } diff --git a/tests/unit/Storage/ReadStreamTest.php b/tests/unit/Storage/ReadStreamTest.php index 0b64fde0d9b3..8e338cbcd1fc 100644 --- a/tests/unit/Storage/ReadStreamTest.php +++ b/tests/unit/Storage/ReadStreamTest.php @@ -20,6 +20,7 @@ use Google\Cloud\Storage\ReadStream; use Google\Cloud\Upload\StreamableUploader; use Prophecy\Argument; +use Psr\Http\Message\StreamInterface; /** * @group storage @@ -28,7 +29,7 @@ class ReadStreamTest extends \PHPUnit_Framework_TestCase { public function testReadsFromHeadersWhenGetSizeIsNull() { - $httpStream = $this->prophesize('Psr\Http\Message\StreamInterface'); + $httpStream = $this->prophesize(StreamInterface::class); $httpStream->getSize()->willReturn(null); $httpStream->getMetadata('wrapper_data')->willReturn([ "Foo: bar", @@ -44,7 +45,7 @@ public function testReadsFromHeadersWhenGetSizeIsNull() public function testReadsFromHeadersWhenGetSizeIsZero() { - $httpStream = $this->prophesize('Psr\Http\Message\StreamInterface'); + $httpStream = $this->prophesize(StreamInterface::class); $httpStream->getSize()->willReturn(0); $httpStream->getMetadata('wrapper_data')->willReturn([ "Foo: bar", @@ -60,7 +61,7 @@ public function testReadsFromHeadersWhenGetSizeIsZero() public function testNoContentLengthHeader() { - $httpStream = $this->prophesize('Psr\Http\Message\StreamInterface'); + $httpStream = $this->prophesize(StreamInterface::class); $httpStream->getSize()->willReturn(null); $httpStream->getMetadata('wrapper_data')->willReturn(array()); diff --git a/tests/unit/Storage/StorageClientTest.php b/tests/unit/Storage/StorageClientTest.php index 8cbb5fff1304..942a79a841df 100644 --- a/tests/unit/Storage/StorageClientTest.php +++ b/tests/unit/Storage/StorageClientTest.php @@ -17,8 +17,13 @@ namespace Google\Cloud\Tests\Unit\Storage; +use Google\Cloud\Core\Timestamp; +use Google\Cloud\Core\Upload\SignedUrlUploader; +use Google\Cloud\Storage\Bucket; +use Google\Cloud\Storage\Connection\ConnectionInterface; use Google\Cloud\Storage\StorageClient; use Google\Cloud\Storage\StreamWrapper; +use GuzzleHttp\Psr7; use Prophecy\Argument; /** @@ -26,18 +31,28 @@ */ class StorageClientTest extends \PHPUnit_Framework_TestCase { + const PROJECT = 'my-project'; public $connection; public function setUp() { - $this->connection = $this->prophesize('Google\Cloud\Storage\Connection\ConnectionInterface'); - $this->client = new StorageTestClient(['projectId' => 'project']); + $this->connection = $this->prophesize(ConnectionInterface::class); + $this->client = \Google\Cloud\Dev\stub(StorageClient::class, [['projectId' => self::PROJECT]]); } public function testGetBucket() { - $this->client->setConnection($this->connection->reveal()); - $this->assertInstanceOf('Google\Cloud\Storage\Bucket', $this->client->bucket('myBucket')); + $this->client->___setProperty('connection', $this->connection->reveal()); + $this->assertInstanceOf(Bucket::class, $this->client->bucket('myBucket')); + } + + public function testGetBucketRequesterPaysDefaultProjectId() + { + $this->connection->getBucket(Argument::withEntry('userProject', self::PROJECT)); + $this->client->___setProperty('connection', $this->connection->reveal()); + $bucket = $this->client->bucket('myBucket', true); + + $bucket->reload(); } public function testGetsBucketsWithoutToken() @@ -48,7 +63,7 @@ public function testGetsBucketsWithoutToken() ] ]); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $buckets = iterator_to_array($this->client->buckets()); $this->assertEquals('bucket1', $buckets[0]->name()); @@ -70,7 +85,7 @@ public function testGetsBucketsWithToken() ] ); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); $bucket = iterator_to_array($this->client->buckets()); $this->assertEquals('bucket2', $bucket[1]->name()); @@ -79,9 +94,9 @@ public function testGetsBucketsWithToken() public function testCreatesBucket() { $this->connection->insertBucket(Argument::any())->willReturn(['name' => 'bucket']); - $this->client->setConnection($this->connection->reveal()); + $this->client->___setProperty('connection', $this->connection->reveal()); - $this->assertInstanceOf('Google\Cloud\Storage\Bucket', $this->client->createBucket('bucket')); + $this->assertInstanceOf(Bucket::class, $this->client->createBucket('bucket')); } public function testRegisteringStreamWrapper() @@ -91,12 +106,21 @@ public function testRegisteringStreamWrapper() $this->assertTrue(in_array('gs', stream_get_wrappers())); $this->client->unregisterStreamWrapper(); } -} -class StorageTestClient extends StorageClient -{ - public function setConnection($connection) + public function testSignedUrlUploader() + { + $uri = 'http://example.com'; + $data = Psr7\stream_for('hello world'); + + $uploader = $this->client->signedUrlUploader($uri, $data); + $this->assertInstanceOf(SignedUrlUploader::class, $uploader); + } + + public function testTimestamp() { - $this->connection = $connection; + $dt = new \DateTime; + $ts = $this->client->timestamp($dt); + $this->assertInstanceOf(Timestamp::class, $ts); + $this->assertEquals($ts->get(), $dt); } } diff --git a/tests/unit/Storage/StorageObjectTest.php b/tests/unit/Storage/StorageObjectTest.php index 2896b4355679..1c7b1c436790 100644 --- a/tests/unit/Storage/StorageObjectTest.php +++ b/tests/unit/Storage/StorageObjectTest.php @@ -18,12 +18,18 @@ namespace Google\Cloud\Tests\Unit\Storage; use Google\Cloud\Core\Exception\NotFoundException; +use Google\Cloud\Core\RequestWrapper; +use Google\Cloud\Core\Timestamp; +use Google\Cloud\Tests\KeyPairGenerateTrait; +use Google\Cloud\Storage\Acl; use Google\Cloud\Storage\Bucket; -use Google\Cloud\Storage\Connection\ConnectionInterface; +use Google\Cloud\Storage\Connection\Rest; use Google\Cloud\Storage\StorageObject; use GuzzleHttp\Psr7; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; /** @@ -31,19 +37,31 @@ */ class StorageObjectTest extends \PHPUnit_Framework_TestCase { - /** @var ConnectionInterface|ObjectProphecy */ + use KeyPairGenerateTrait; + + const TIMESTAMP = '2025-01-01'; + + /** @var Rest|ObjectProphecy */ public $connection; + private $key; + private $kf; + public function setUp() { - $this->connection = $this->prophesize(ConnectionInterface::class); + $this->connection = $this->prophesize(Rest::class); + $this->key = $this->getKeyPair(); + $this->kf = $kf = [ + 'private_key' => $this->key[0], + 'client_email' => 'test@example.com' + ]; } public function testGetAcl() { $object = new StorageObject($this->connection->reveal(), 'object.txt', 'bucket'); - $this->assertInstanceOf('Google\Cloud\Storage\Acl', $object->acl()); + $this->assertInstanceOf(Acl::class, $object->acl()); } public function testDoesExistTrue() @@ -151,7 +169,7 @@ public function testCopyObjectWithNewName() { $sourceBucket = 'bucket'; $sourceObject = 'object.txt'; - $bucketConnection = $this->prophesize(ConnectionInterface::class)->reveal(); + $bucketConnection = $this->prophesize(Rest::class)->reveal(); $destinationBucketName = 'bucket2'; $destinationBucket = new Bucket($bucketConnection, $destinationBucketName); $destinationObject = 'object2.txt'; @@ -240,7 +258,7 @@ public function testRewriteObjectWithNewName() { $sourceBucket = 'bucket'; $sourceObject = 'object.txt'; - $bucketConnection = $this->prophesize(ConnectionInterface::class)->reveal(); + $bucketConnection = $this->prophesize(Rest::class)->reveal(); $destinationBucketName = 'bucket2'; $destinationBucket = new Bucket($bucketConnection, $destinationBucketName); $destinationObject = 'object2.txt'; @@ -514,6 +532,279 @@ public function testGetsGcsUri() $this->assertEquals($expectedUri, $object->gcsUri()); } + public function testSignedUrl() + { + $object = new StorageObjectSignatureStub($this->connection->reveal(), $name = 'object.txt', $bucketName = 'bucket', 'foo'); + $ts = new Timestamp(new \DateTime(self::TIMESTAMP)); + + $seconds = $ts->get()->format('U'); + + $contentType = $responseType = 'text/plain'; + $digest = base64_encode(md5('hello world')); + + $url = $object->signedUrl($ts, [ + 'keyFile' => $this->kf, + 'headers' => [ + 'foo' => ['bar', 'bar'], + 'bat' => 'baz' + ], + 'contentType' => $contentType, + 'responseDisposition' => 'foo', + 'responseType' => $responseType, + 'contentMd5' => $digest + ]); + + $input = implode(PHP_EOL, [ + 'GET', + $digest, + $contentType, + $seconds, + 'foo:bar,bar', + 'bat:baz', + '/bucket/object.txt' + ]); + + $query = explode('?', $url)[1]; + $pieces = explode('&', $query); + + $signature = trim(current(array_filter($pieces, function ($piece) { + return strpos($piece, 'Signature') !== false; + })), 'Signature='); + + $this->assertTrue($object->___signatureIsCorrect($signature)); + $this->assertEquals($object->input, $input); + $this->assertTrue(in_array('generation=foo', $pieces)); + $this->assertTrue(in_array('response-content-type='. urlencode($contentType), $pieces)); + $this->assertTrue(in_array('response-content-disposition=foo', $pieces)); + $this->assertTrue(in_array('response-content-type='. urlencode($responseType), $pieces)); + } + + public function testSignedUrlWithSaveAsName() + { + $object = new StorageObjectSignatureStub($this->connection->reveal(), $name = 'object.txt', $bucketName = 'bucket'); + $ts = new Timestamp(new \DateTime(self::TIMESTAMP)); + + $seconds = $ts->get()->format('U'); + + $url = $object->signedUrl($ts, [ + 'keyFile' => $this->kf, + 'saveAsName' => 'foo' + ]); + + $input = implode(PHP_EOL, [ + 'GET', + '', + '', + $seconds, + '/bucket/object.txt' + ]); + + $query = explode('?', $url)[1]; + $pieces = explode('&', $query); + + $signature = trim(current(array_filter($pieces, function ($piece) { + return strpos($piece, 'Signature') !== false; + })), 'Signature='); + + $this->assertTrue($object->___signatureIsCorrect($signature)); + $this->assertEquals($object->input, $input); + $this->assertTrue(in_array('response-content-disposition=attachment;filename="foo"', $pieces)); + } + + public function testSignedUrlConnectionKeyfile() + { + $rw = $this->prophesize(RequestWrapper::class); + $rw->keyFile()->willReturn($this->kf); + + $conn = $this->prophesize(Rest::class); + $conn->requestWrapper()->willReturn($rw->reveal()); + + $object = new StorageObjectSignatureStub($conn->reveal(), $name = 'object.txt', $bucketName = 'bucket'); + $ts = new Timestamp(new \DateTime(self::TIMESTAMP)); + + $seconds = $ts->get()->format('U'); + + $url = $object->signedUrl($ts); + + $input = implode(PHP_EOL, [ + 'GET', + '', + '', + $seconds, + '/bucket/object.txt' + ]); + + $query = explode('?', $url)[1]; + $pieces = explode('&', $query); + + $signature = trim(current(array_filter($pieces, function ($piece) { + return strpos($piece, 'Signature') !== false; + })), 'Signature='); + + $this->assertTrue($object->___signatureIsCorrect($signature)); + $this->assertEquals($object->input, $input); + } + + public function testSignedUploadUrl() + { + $object = new StorageObjectSignatureStub($this->connection->reveal(), $name = 'object.txt', $bucketName = 'bucket', 'foo'); + $ts = new Timestamp(new \DateTime(self::TIMESTAMP)); + + $seconds = $ts->get()->format('U'); + + $contentType = $responseType = 'text/plain'; + $digest = base64_encode(md5('hello world')); + + $url = $object->signedUploadUrl($ts, [ + 'keyFile' => $this->kf, + 'headers' => [ + 'foo' => ['bar', 'bar'], + 'bat' => 'baz' + ], + 'contentType' => $contentType, + 'contentMd5' => $digest + ]); + + $input = implode(PHP_EOL, [ + 'POST', + $digest, + $contentType, + $seconds, + 'foo:bar,bar', + 'bat:baz', + 'x-goog-resumable:start', + '/bucket/object.txt' + ]); + + $query = explode('?', $url)[1]; + $pieces = explode('&', $query); + + $signature = trim(current(array_filter($pieces, function ($piece) { + return strpos($piece, 'Signature') !== false; + })), 'Signature='); + + $this->assertTrue($object->___signatureIsCorrect($signature)); + $this->assertEquals($object->input, $input); + } + + public function testBeginSignedUploadSession() + { + $ts = new Timestamp(new \DateTime('+1 minute')); + + $seconds = $ts->get()->format('U'); + + $rw = $this->prophesize(RequestWrapper::class); + $test = $this; + $sessionUri = 'http://example.com'; + + $rw->send(Argument::type(RequestInterface::class), Argument::type('array')) + ->will(function($args) use ($sessionUri, $test) { + + $res = $test->prophesize(ResponseInterface::class); + $res->getHeaderLine('Location') + ->willReturn($sessionUri); + + return $res->reveal(); + }); + + $this->connection->requestWrapper() + ->willReturn($rw->reveal()); + + $object = new StorageObjectSignatureStub($this->connection->reveal(), $name = 'object.txt', $bucketName = 'bucket', 'foo'); + + $uri = $object->beginSignedUploadSession([ + 'keyFile' => $this->kf, + ]); + + $this->assertEquals($sessionUri, $uri); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testSignedUrlInvalidExpiration() + { + $object = new StorageObject($this->connection->reveal(), $name = 'object.txt', $bucketName = 'bucket'); + $ts = new Timestamp(new \DateTime('yesterday')); + $object->signedUrl($ts); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testSignedUrlInvalidMethod() + { + $object = new StorageObject($this->connection->reveal(), $name = 'object.txt', $bucketName = 'bucket'); + $ts = new Timestamp(new \DateTime(self::TIMESTAMP)); + $object->signedUrl($ts, [ + 'method' => 'FOO' + ]); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testSignedUrlInvalidMethodMissingAllowPostOption() + { + $object = new StorageObject($this->connection->reveal(), $name = 'object.txt', $bucketName = 'bucket'); + $ts = new Timestamp(new \DateTime(self::TIMESTAMP)); + $object->signedUrl($ts, [ + 'method' => 'POST' + ]); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testSignedUrlInvalidKeyFilePath() + { + $object = new StorageObject($this->connection->reveal(), $name = 'object.txt', $bucketName = 'bucket'); + $ts = new Timestamp(new \DateTime(self::TIMESTAMP)); + + $url = $object->signedUrl($ts, [ + 'keyFilePath' => __DIR__ .'/InfiniteMonkeysOnInfiniteKeyboardsWouldTypeThisStringGivenInfiniteTime.json', + ]); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testSignedUrlInvalidKeyFilePathData() + { + $object = new StorageObject($this->connection->reveal(), $name = 'object.txt', $bucketName = 'bucket'); + $ts = new Timestamp(new \DateTime(self::TIMESTAMP)); + + $url = $object->signedUrl($ts, [ + 'keyFilePath' => __FILE__, + ]); + } + + /** + * @expectedException RuntimeException + */ + public function testSignedUrlInvalidKeyFileMissingPrivateKey() + { + $object = new StorageObject($this->connection->reveal(), $name = 'object.txt', $bucketName = 'bucket'); + $ts = new Timestamp(new \DateTime(self::TIMESTAMP)); + + $url = $object->signedUrl($ts, [ + 'keyFile' => ['client_email' => 'test@example.com'], + ]); + } + + /** + * @expectedException RuntimeException + */ + public function testSignedUrlInvalidKeyFileMissingClientEmail() + { + $object = new StorageObject($this->connection->reveal(), $name = 'object.txt', $bucketName = 'bucket'); + $ts = new Timestamp(new \DateTime(self::TIMESTAMP)); + + $url = $object->signedUrl($ts, [ + 'keyFile' => ['private_key' => '-----BEGIN PRIVATE KEY-----'], + ]); + } + public function testRequesterPays() { $this->connection->getObject(Argument::withEntry('userProject', 'foo')) @@ -524,3 +815,20 @@ public function testRequesterPays() $object->reload(); } } + +class StorageObjectSignatureStub extends StorageObject +{ + const SIGNATURE = 'foo'; + public $input; + + protected function signString($privateKey, $data, $forceOpenssl = false) + { + $this->input = $data; + return self::SIGNATURE; + } + + public function ___signatureIsCorrect($signature) + { + return base64_decode(urldecode($signature)) === self::SIGNATURE; + } +}