From 3978ea9a47e0827e860d60c53e2406b7e84220cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 9 Jul 2024 22:24:55 +0200 Subject: [PATCH] Update StoredObjectManager to handle versioned StoredObjects The StoredObjectManager and related test cases have been updated to handle versioned StoredObjects, allowing the same methods to work with either a StoredObject or its versions. The changes also involve return information for the write method and enhancements to the write test procedure. This provides more functionality and flexibility for handling StoredObjects in different versions. --- .../Service/StoredObjectManager.php | 98 +++++++----- .../Service/StoredObjectManagerInterface.php | 34 ++++- .../Tests/Service/StoredObjectManagerTest.php | 144 +++++++++++++----- 3 files changed, 199 insertions(+), 77 deletions(-) diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php index 7100ca821..b10f67619 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManager.php @@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\Service; use Base64Url\Base64Url; use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Entity\StoredObjectVersion; use Chill\DocStoreBundle\Exception\StoredObjectManagerException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -32,10 +33,12 @@ final class StoredObjectManager implements StoredObjectManagerInterface private readonly TempUrlGeneratorInterface $tempUrlGenerator ) {} - public function getLastModified(StoredObject $document): \DateTimeInterface + public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface { - if ($this->hasCache($document)) { - $response = $this->getResponseFromCache($document); + $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document; + + if ($this->hasCache($version)) { + $response = $this->getResponseFromCache($version); } else { try { $response = $this @@ -46,7 +49,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface ->tempUrlGenerator ->generate( Request::METHOD_HEAD, - $document->getFilename() + $version->getFilename() ) ->url ); @@ -58,11 +61,13 @@ final class StoredObjectManager implements StoredObjectManagerInterface return $this->extractLastModifiedFromResponse($response); } - public function getContentLength(StoredObject $document): int + public function getContentLength(StoredObject|StoredObjectVersion $document): int { - if ([] === $document->getKeyInfos()) { - if ($this->hasCache($document)) { - $response = $this->getResponseFromCache($document); + $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document; + + if (!$this->isVersionEncrypted($version)) { + if ($this->hasCache($version)) { + $response = $this->getResponseFromCache($version); } else { try { $response = $this @@ -73,7 +78,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface ->tempUrlGenerator ->generate( Request::METHOD_HEAD, - $document->getFilename() + $version->getFilename() ) ->url ); @@ -88,10 +93,12 @@ final class StoredObjectManager implements StoredObjectManagerInterface return strlen($this->read($document)); } - public function etag(StoredObject $document): string + public function etag(StoredObject|StoredObjectVersion $document): string { - if ($this->hasCache($document)) { - $response = $this->getResponseFromCache($document); + $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document; + + if ($this->hasCache($version)) { + $response = $this->getResponseFromCache($version); } else { try { $response = $this @@ -111,12 +118,14 @@ final class StoredObjectManager implements StoredObjectManagerInterface } } - return $this->extractEtagFromResponse($response, $document); + return $this->extractEtagFromResponse($response); } - public function read(StoredObject $document): string + public function read(StoredObject|StoredObjectVersion $document, ?int $version = null): string { - $response = $this->getResponseFromCache($document); + $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document; + + $response = $this->getResponseFromCache($version); try { $data = $response->getContent(); @@ -124,7 +133,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface throw StoredObjectManagerException::unableToGetResponseContent($e); } - if (false === $this->hasKeysAndIv($document)) { + if (!$this->isVersionEncrypted($version)) { return $data; } @@ -132,9 +141,9 @@ final class StoredObjectManager implements StoredObjectManagerInterface $data, self::ALGORITHM, // TODO: Why using this library and not use base64_decode() ? - Base64Url::decode($document->getKeyInfos()['k']), + Base64Url::decode($version->getKeyInfos()['k']), \OPENSSL_RAW_DATA, - pack('C*', ...$document->getIv()) + pack('C*', ...$version->getIv()) ); if (false === $clearData) { @@ -144,20 +153,25 @@ final class StoredObjectManager implements StoredObjectManagerInterface return $clearData; } - public function write(StoredObject $document, string $clearContent): void + public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion { - if ($this->hasCache($document)) { - unset($this->inMemory[$document->getUuid()->toString()]); - } + $newIv = $document->getIv(); + $newKey = $document->getKeyInfos(); + $newType = $contentType ?? $document->getType(); + $version = $document->registerVersion( + $newIv, + $newKey, + $newType + ); - $encryptedContent = $this->hasKeysAndIv($document) + $encryptedContent = $this->isVersionEncrypted($version) ? openssl_encrypt( $clearContent, self::ALGORITHM, // TODO: Why using this library and not use base64_decode() ? - Base64Url::decode($document->getKeyInfos()['k']), + Base64Url::decode($version->getKeyInfos()['k']), \OPENSSL_RAW_DATA, - pack('C*', ...$document->getIv()) + pack('C*', ...$version->getIv()) ) : $clearContent; @@ -176,7 +190,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface ->tempUrlGenerator ->generate( Request::METHOD_PUT, - $document->getFilename() + $version->getFilename() ) ->url, [ @@ -191,6 +205,8 @@ final class StoredObjectManager implements StoredObjectManagerInterface if (Response::HTTP_CREATED !== $response->getStatusCode()) { throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode()); } + + return $version; } public function clearCache(): void @@ -215,12 +231,19 @@ final class StoredObjectManager implements StoredObjectManagerInterface return $date; } + /** + * Extracts the content length from a ResponseInterface object. + * + * Does work only if the object is not encrypted. + * + * @return int the extracted content length as an integer + */ private function extractContentLengthFromResponse(ResponseInterface $response): int { return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0]; } - private function extractEtagFromResponse(ResponseInterface $response, StoredObject $storedObject): ?string + private function extractEtagFromResponse(ResponseInterface $response): ?string { $etag = ($response->getHeaders()['etag'] ?? [''])[0]; @@ -231,7 +254,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface return $etag; } - private function fillCache(StoredObject $document): void + private function fillCache(StoredObjectVersion $document): void { try { $response = $this @@ -254,25 +277,30 @@ final class StoredObjectManager implements StoredObjectManagerInterface throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode()); } - $this->inMemory[$document->getUuid()->toString()] = $response; + $this->inMemory[$this->buildCacheKey($document)] = $response; } - private function getResponseFromCache(StoredObject $document): ResponseInterface + private function buildCacheKey(StoredObjectVersion $storedObjectVersion): string + { + return $storedObjectVersion->getStoredObject()->getUuid()->toString().$storedObjectVersion->getId(); + } + + private function getResponseFromCache(StoredObjectVersion $document): ResponseInterface { if (!$this->hasCache($document)) { $this->fillCache($document); } - return $this->inMemory[$document->getUuid()->toString()]; + return $this->inMemory[$this->buildCacheKey($document)]; } - private function hasCache(StoredObject $document): bool + private function hasCache(StoredObjectVersion $document): bool { - return \array_key_exists($document->getUuid()->toString(), $this->inMemory); + return \array_key_exists($this->buildCacheKey($document), $this->inMemory); } - private function hasKeysAndIv(StoredObject $storedObject): bool + private function isVersionEncrypted(StoredObjectVersion $storedObjectVersion): bool { - return ([] !== $storedObject->getKeyInfos()) && ([] !== $storedObject->getIv()); + return ([] !== $storedObjectVersion->getKeyInfos()) && ([] !== $storedObjectVersion->getIv()); } } diff --git a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php index 19ff974f0..4d2f45c33 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php +++ b/src/Bundle/ChillDocStoreBundle/Service/StoredObjectManagerInterface.php @@ -12,36 +12,56 @@ declare(strict_types=1); namespace Chill\DocStoreBundle\Service; use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Entity\StoredObjectVersion; use Chill\DocStoreBundle\Exception\StoredObjectManagerException; interface StoredObjectManagerInterface { - public function getLastModified(StoredObject $document): \DateTimeInterface; + /** + * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used + */ + public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface; - public function getContentLength(StoredObject $document): int; + /** + * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used + */ + public function getContentLength(StoredObject|StoredObjectVersion $document): int; /** * Get the content of a StoredObject. * - * @param StoredObject $document the document + * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used * * @return string the retrieved content in clear * * @throws StoredObjectManagerException if unable to read or decrypt the content */ - public function read(StoredObject $document): string; + public function read(StoredObject|StoredObjectVersion $document): string; /** * Set the content of a StoredObject. * * @param StoredObject $document the document - * @param $clearContent The content to store in clear + * @param string $clearContent The content to store in clear + * @param string|null $contentType The new content type. If set to null, the content-type is supposed not to change. If there is no content type, an empty string will be used. + * + * @return StoredObjectVersion the newly created @see{StoredObjectVersion} for the given @see{StoredObject} * * @throws StoredObjectManagerException */ - public function write(StoredObject $document, string $clearContent): void; + public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion; - public function etag(StoredObject $document): string; + /** + * return or compute the etag for the document. + * + * @param StoredObject|StoredObjectVersion $document if a StoredObject is given, the last version will be used + * + * @return string the etag of this document + */ + public function etag(StoredObject|StoredObjectVersion $document): string; + /** + * Clears the cache for the stored object. + */ public function clearCache(): void; } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php index c5cc8185e..c11a06f9b 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Service/StoredObjectManagerTest.php @@ -31,23 +31,25 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; */ final class StoredObjectManagerTest extends TestCase { - public static function getDataProvider(): \Generator + public static function getDataProviderForRead(): \Generator { /* HAPPY SCENARIO */ // Encrypted object yield [ (new StoredObject()) - ->setFilename('encrypted.txt') - ->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')]) - ->setIv(unpack('C*', 'abcdefghijklmnop')), + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')], + filename: 'encrypted.txt' + )->getStoredObject(), hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string 'The quick brown fox jumps over the lazy dog', // clear ]; // Non-encrypted object yield [ - (new StoredObject())->setFilename('non-encrypted.txt'), // The StoredObject + (new StoredObject())->registerVersion(filename: 'non-encrypted.txt')->getStoredObject(), // The StoredObject 'The quick brown fox jumps over the lazy dog', // Encrypted 'The quick brown fox jumps over the lazy dog', // Clear ]; @@ -57,9 +59,11 @@ final class StoredObjectManagerTest extends TestCase // Encrypted object with issue during HTTP communication yield [ (new StoredObject()) - ->setFilename('error_during_http_request.txt') - ->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')]) - ->setIv(unpack('C*', 'abcdefghijklmnop')), + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')], + filename: 'error_during_http_request.txt' + )->getStoredObject(), hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string 'The quick brown fox jumps over the lazy dog', // clear StoredObjectManagerException::class, @@ -68,9 +72,11 @@ final class StoredObjectManagerTest extends TestCase // Encrypted object with issue during HTTP communication: Invalid status code yield [ (new StoredObject()) - ->setFilename('invalid_statuscode.txt') - ->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')]) - ->setIv(unpack('C*', 'abcdefghijklmnop')), + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')], + filename: 'invalid_statuscode.txt' + )->getStoredObject(), hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string 'The quick brown fox jumps over the lazy dog', // clear StoredObjectManagerException::class, @@ -79,17 +85,73 @@ final class StoredObjectManagerTest extends TestCase // Erroneous encrypted: Unable to decrypt exception. yield [ (new StoredObject()) - ->setFilename('unable_to_decrypt.txt') - ->setKeyInfos(['k' => base64_encode('WRONG_PASS_PHRASE')]) - ->setIv(unpack('C*', 'abcdefghijklmnop')), + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('WRONG_PASS_PHRASE')], + filename: 'unable_to_decrypt.txt' + )->getStoredObject(), 'WRONG_ENCODED_VALUE', // Binary encoded string 'The quick brown fox jumps over the lazy dog', // clear StoredObjectManagerException::class, ]; } + public static function getDataProviderForWrite(): \Generator + { + /* HAPPY SCENARIO */ + + // Encrypted object + yield [ + (new StoredObject()) + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')], + filename: 'encrypted.txt' + )->getStoredObject(), + hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string + 'The quick brown fox jumps over the lazy dog', // clear + ]; + + // Non-encrypted object + yield [ + (new StoredObject())->registerVersion(filename: 'non-encrypted.txt')->getStoredObject(), // The StoredObject + 'The quick brown fox jumps over the lazy dog', // Encrypted + 'The quick brown fox jumps over the lazy dog', // Clear + ]; + + /* UNHAPPY SCENARIO */ + + // Encrypted object with issue during HTTP communication + yield [ + (new StoredObject()) + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')], + filename: 'error_during_http_request.txt' + )->getStoredObject(), + hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string + 'The quick brown fox jumps over the lazy dog', // clear + StoredObjectManagerException::class, + -1, + ]; + + // Encrypted object with issue during HTTP communication: Invalid status code + yield [ + (new StoredObject()) + ->registerVersion( + unpack('C*', 'abcdefghijklmnop'), + ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')], + filename: 'invalid_statuscode.txt' + )->getStoredObject(), + hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string + 'The quick brown fox jumps over the lazy dog', // clear + StoredObjectManagerException::class, + 408, + ]; + } + /** - * @dataProvider getDataProvider + * @dataProvider getDataProviderForRead */ public function testRead(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null) { @@ -103,19 +165,37 @@ final class StoredObjectManagerTest extends TestCase } /** - * @dataProvider getDataProvider + * @dataProvider getDataProviderForWrite */ - public function testWrite(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null) + public function testWrite(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null, ?int $errorCode = null) { if (null !== $exceptionClass) { $this->expectException($exceptionClass); } - $storedObjectManager = $this->getSubject($storedObject, $encodedContent); + $previousVersion = $storedObject->getCurrentVersion(); + $previousFilename = $previousVersion->getFilename(); - $storedObjectManager->write($storedObject, $clearContent); + $client = new MockHttpClient(function ($method, $url, $options) use ($encodedContent, $previousFilename, $errorCode) { + self::assertEquals('PUT', $method); + self::assertStringStartsWith('https://example.com/', $url); + self::assertStringNotContainsString($previousFilename, $url, 'test that the PUT operation is not performed on the same file'); + self::assertArrayHasKey('body', $options); + self::assertEquals($encodedContent, $options['body']); - self::assertEquals($clearContent, $storedObjectManager->read($storedObject)); + if (-1 === $errorCode) { + throw new TransportException(); + } + + return new MockResponse('', ['http_code' => $errorCode ?? 201]); + }); + + $storedObjectManager = new StoredObjectManager($client, $this->getTempUrlGenerator($storedObject)); + + $newVersion = $storedObjectManager->write($storedObject, $clearContent); + + self::assertNotSame($previousVersion, $newVersion); + self::assertSame($storedObject->getCurrentVersion(), $newVersion); } public function testWriteWithDeleteAt() @@ -170,19 +250,6 @@ final class StoredObjectManagerTest extends TestCase } } - if (Request::METHOD_PUT === $method) { - switch ($url) { - case 'https://example.com/non-encrypted.txt': - case 'https://example.com/encrypted.txt': - return new MockResponse($encodedContent, ['http_code' => 201]); - - case 'https://example.com/error_during_http_request.txt': - throw new TransportException('error_during_http_request.txt'); - case 'https://example.com/invalid_statuscode.txt': - return new MockResponse($encodedContent, ['http_code' => 404]); - } - } - return new MockResponse('Not found'); }; @@ -209,9 +276,16 @@ final class StoredObjectManagerTest extends TestCase $tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class); $tempUrlGenerator + ->expects($this->atLeastOnce()) ->method('generate') - ->withAnyParameters() - ->willReturn($response); + ->with($this->logicalOr($this->identicalTo('GET'), $this->identicalTo('PUT')), $this->isType('string')) + ->willReturnCallback(function (string $method, string $objectName) { + return new SignedUrl( + $method, + 'https://example.com/'.$objectName, + new \DateTimeImmutable('1 hours') + ); + }); return $tempUrlGenerator; }