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.
This commit is contained in:
Julien Fastré 2024-07-09 22:24:55 +02:00
parent 4fbb7811ac
commit 3978ea9a47
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
3 changed files with 199 additions and 77 deletions

View File

@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\Service;
use Base64Url\Base64Url; use Base64Url\Base64Url;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface; use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException; use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@ -32,10 +33,12 @@ final class StoredObjectManager implements StoredObjectManagerInterface
private readonly TempUrlGeneratorInterface $tempUrlGenerator private readonly TempUrlGeneratorInterface $tempUrlGenerator
) {} ) {}
public function getLastModified(StoredObject $document): \DateTimeInterface public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface
{ {
if ($this->hasCache($document)) { $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
$response = $this->getResponseFromCache($document);
if ($this->hasCache($version)) {
$response = $this->getResponseFromCache($version);
} else { } else {
try { try {
$response = $this $response = $this
@ -46,7 +49,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->tempUrlGenerator ->tempUrlGenerator
->generate( ->generate(
Request::METHOD_HEAD, Request::METHOD_HEAD,
$document->getFilename() $version->getFilename()
) )
->url ->url
); );
@ -58,11 +61,13 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $this->extractLastModifiedFromResponse($response); return $this->extractLastModifiedFromResponse($response);
} }
public function getContentLength(StoredObject $document): int public function getContentLength(StoredObject|StoredObjectVersion $document): int
{ {
if ([] === $document->getKeyInfos()) { $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if ($this->hasCache($document)) {
$response = $this->getResponseFromCache($document); if (!$this->isVersionEncrypted($version)) {
if ($this->hasCache($version)) {
$response = $this->getResponseFromCache($version);
} else { } else {
try { try {
$response = $this $response = $this
@ -73,7 +78,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->tempUrlGenerator ->tempUrlGenerator
->generate( ->generate(
Request::METHOD_HEAD, Request::METHOD_HEAD,
$document->getFilename() $version->getFilename()
) )
->url ->url
); );
@ -88,10 +93,12 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return strlen($this->read($document)); return strlen($this->read($document));
} }
public function etag(StoredObject $document): string public function etag(StoredObject|StoredObjectVersion $document): string
{ {
if ($this->hasCache($document)) { $version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
$response = $this->getResponseFromCache($document);
if ($this->hasCache($version)) {
$response = $this->getResponseFromCache($version);
} else { } else {
try { try {
$response = $this $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 { try {
$data = $response->getContent(); $data = $response->getContent();
@ -124,7 +133,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
throw StoredObjectManagerException::unableToGetResponseContent($e); throw StoredObjectManagerException::unableToGetResponseContent($e);
} }
if (false === $this->hasKeysAndIv($document)) { if (!$this->isVersionEncrypted($version)) {
return $data; return $data;
} }
@ -132,9 +141,9 @@ final class StoredObjectManager implements StoredObjectManagerInterface
$data, $data,
self::ALGORITHM, self::ALGORITHM,
// TODO: Why using this library and not use base64_decode() ? // TODO: Why using this library and not use base64_decode() ?
Base64Url::decode($document->getKeyInfos()['k']), Base64Url::decode($version->getKeyInfos()['k']),
\OPENSSL_RAW_DATA, \OPENSSL_RAW_DATA,
pack('C*', ...$document->getIv()) pack('C*', ...$version->getIv())
); );
if (false === $clearData) { if (false === $clearData) {
@ -144,20 +153,25 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $clearData; 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)) { $newIv = $document->getIv();
unset($this->inMemory[$document->getUuid()->toString()]); $newKey = $document->getKeyInfos();
} $newType = $contentType ?? $document->getType();
$version = $document->registerVersion(
$newIv,
$newKey,
$newType
);
$encryptedContent = $this->hasKeysAndIv($document) $encryptedContent = $this->isVersionEncrypted($version)
? openssl_encrypt( ? openssl_encrypt(
$clearContent, $clearContent,
self::ALGORITHM, self::ALGORITHM,
// TODO: Why using this library and not use base64_decode() ? // TODO: Why using this library and not use base64_decode() ?
Base64Url::decode($document->getKeyInfos()['k']), Base64Url::decode($version->getKeyInfos()['k']),
\OPENSSL_RAW_DATA, \OPENSSL_RAW_DATA,
pack('C*', ...$document->getIv()) pack('C*', ...$version->getIv())
) )
: $clearContent; : $clearContent;
@ -176,7 +190,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->tempUrlGenerator ->tempUrlGenerator
->generate( ->generate(
Request::METHOD_PUT, Request::METHOD_PUT,
$document->getFilename() $version->getFilename()
) )
->url, ->url,
[ [
@ -191,6 +205,8 @@ final class StoredObjectManager implements StoredObjectManagerInterface
if (Response::HTTP_CREATED !== $response->getStatusCode()) { if (Response::HTTP_CREATED !== $response->getStatusCode()) {
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode()); throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
} }
return $version;
} }
public function clearCache(): void public function clearCache(): void
@ -215,12 +231,19 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $date; 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 private function extractContentLengthFromResponse(ResponseInterface $response): int
{ {
return (int) ($response->getHeaders()['content-length'] ?? ['0'])[0]; 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]; $etag = ($response->getHeaders()['etag'] ?? [''])[0];
@ -231,7 +254,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return $etag; return $etag;
} }
private function fillCache(StoredObject $document): void private function fillCache(StoredObjectVersion $document): void
{ {
try { try {
$response = $this $response = $this
@ -254,25 +277,30 @@ final class StoredObjectManager implements StoredObjectManagerInterface
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode()); 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)) { if (!$this->hasCache($document)) {
$this->fillCache($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());
} }
} }

View File

@ -12,36 +12,56 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Service; namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException; use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
interface StoredObjectManagerInterface 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. * 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 * @return string the retrieved content in clear
* *
* @throws StoredObjectManagerException if unable to read or decrypt the content * @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. * Set the content of a StoredObject.
* *
* @param StoredObject $document the document * @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 * @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; public function clearCache(): void;
} }

View File

@ -31,23 +31,25 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/ */
final class StoredObjectManagerTest extends TestCase final class StoredObjectManagerTest extends TestCase
{ {
public static function getDataProvider(): \Generator public static function getDataProviderForRead(): \Generator
{ {
/* HAPPY SCENARIO */ /* HAPPY SCENARIO */
// Encrypted object // Encrypted object
yield [ yield [
(new StoredObject()) (new StoredObject())
->setFilename('encrypted.txt') ->registerVersion(
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')]) unpack('C*', 'abcdefghijklmnop'),
->setIv(unpack('C*', 'abcdefghijklmnop')), ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
filename: 'encrypted.txt'
)->getStoredObject(),
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear 'The quick brown fox jumps over the lazy dog', // clear
]; ];
// Non-encrypted object // Non-encrypted object
yield [ 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', // Encrypted
'The quick brown fox jumps over the lazy dog', // Clear '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 // Encrypted object with issue during HTTP communication
yield [ yield [
(new StoredObject()) (new StoredObject())
->setFilename('error_during_http_request.txt') ->registerVersion(
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')]) unpack('C*', 'abcdefghijklmnop'),
->setIv(unpack('C*', 'abcdefghijklmnop')), ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
filename: 'error_during_http_request.txt'
)->getStoredObject(),
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear 'The quick brown fox jumps over the lazy dog', // clear
StoredObjectManagerException::class, StoredObjectManagerException::class,
@ -68,9 +72,11 @@ final class StoredObjectManagerTest extends TestCase
// Encrypted object with issue during HTTP communication: Invalid status code // Encrypted object with issue during HTTP communication: Invalid status code
yield [ yield [
(new StoredObject()) (new StoredObject())
->setFilename('invalid_statuscode.txt') ->registerVersion(
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')]) unpack('C*', 'abcdefghijklmnop'),
->setIv(unpack('C*', 'abcdefghijklmnop')), ['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')],
filename: 'invalid_statuscode.txt'
)->getStoredObject(),
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear 'The quick brown fox jumps over the lazy dog', // clear
StoredObjectManagerException::class, StoredObjectManagerException::class,
@ -79,17 +85,73 @@ final class StoredObjectManagerTest extends TestCase
// Erroneous encrypted: Unable to decrypt exception. // Erroneous encrypted: Unable to decrypt exception.
yield [ yield [
(new StoredObject()) (new StoredObject())
->setFilename('unable_to_decrypt.txt') ->registerVersion(
->setKeyInfos(['k' => base64_encode('WRONG_PASS_PHRASE')]) unpack('C*', 'abcdefghijklmnop'),
->setIv(unpack('C*', 'abcdefghijklmnop')), ['k' => base64_encode('WRONG_PASS_PHRASE')],
filename: 'unable_to_decrypt.txt'
)->getStoredObject(),
'WRONG_ENCODED_VALUE', // Binary encoded string 'WRONG_ENCODED_VALUE', // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear 'The quick brown fox jumps over the lazy dog', // clear
StoredObjectManagerException::class, 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) 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) { if (null !== $exceptionClass) {
$this->expectException($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() 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'); return new MockResponse('Not found');
}; };
@ -209,9 +276,16 @@ final class StoredObjectManagerTest extends TestCase
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class); $tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
$tempUrlGenerator $tempUrlGenerator
->expects($this->atLeastOnce())
->method('generate') ->method('generate')
->withAnyParameters() ->with($this->logicalOr($this->identicalTo('GET'), $this->identicalTo('PUT')), $this->isType('string'))
->willReturn($response); ->willReturnCallback(function (string $method, string $objectName) {
return new SignedUrl(
$method,
'https://example.com/'.$objectName,
new \DateTimeImmutable('1 hours')
);
});
return $tempUrlGenerator; return $tempUrlGenerator;
} }