feat: Add new StoredObjectManager service.

To read and write onto `StoredObject` document using a common interface.
This commit is contained in:
Pol Dellaiera 2022-03-08 15:45:39 +01:00
parent 8abed67e1c
commit 62af980ea5
No known key found for this signature in database
GPG Key ID: D476DFE9C67467CA
4 changed files with 390 additions and 0 deletions

View File

@ -0,0 +1,40 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\DocStoreBundle\Exception;
use Exception;
use Throwable;
final class StoredObjectManagerException extends Exception
{
public static function errorDuringHttpRequest(Throwable $exception): self
{
return new self('Error during HTTP request.', 500, $exception);
}
public static function invalidStatusCode(int $code): self
{
return new self(
sprintf('Invalid status code received (%s).', $code)
);
}
public static function unableToDecrypt(string $message): self
{
return new self(sprintf('Unable to decrypt content (reason: %s).', $message));
}
public static function unableToGetResponseContent(Throwable $exception): self
{
return new self('Unable to get content from response.', 500, $exception);
}
}

View File

@ -0,0 +1,133 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\DocStoreBundle\Service;
use Base64Url\Base64Url;
use ChampsLibres\AsyncUploaderBundle\TempUrl\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
use const OPENSSL_RAW_DATA;
final class StoredObjectManager implements StoredObjectManagerInterface
{
private const ALGORITHM = 'AES-256-CBC';
private HttpClientInterface $client;
private TempUrlGeneratorInterface $tempUrlGenerator;
public function __construct(
HttpClientInterface $client,
TempUrlGeneratorInterface $tempUrlGenerator
) {
$this->client = $client;
$this->tempUrlGenerator = $tempUrlGenerator;
}
public function read(StoredObject $document): string
{
try {
$response = $this
->client
->request(
Request::METHOD_GET,
$this
->tempUrlGenerator
->generate(
Request::METHOD_GET,
$document->getFilename()
)
->url
);
} catch (Throwable $e) {
throw StoredObjectManagerException::errorDuringHttpRequest($e);
}
if ($response->getStatusCode() !== Response::HTTP_OK) {
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
}
try {
$data = $response->getContent();
} catch (Throwable $e) {
throw StoredObjectManagerException::unableToGetResponseContent($e);
}
if (false === $this->hasKeysAndIv($document)) {
return $data;
}
$clearData = openssl_decrypt(
$data,
self::ALGORITHM,
// TODO: Why using this library and not use base64_decode() ?
Base64Url::decode($document->getKeyInfos()['k']),
OPENSSL_RAW_DATA,
pack('C*', ...$document->getIv())
);
if (false === $clearData) {
throw StoredObjectManagerException::unableToDecrypt(openssl_error_string());
}
return $clearData;
}
public function write(StoredObject $document, string $clearContent): void
{
$encryptedContent = $this->hasKeysAndIv($document)
? openssl_encrypt(
$clearContent,
self::ALGORITHM,
// TODO: Why using this library and not use base64_decode() ?
Base64Url::decode($document->getKeyInfos()['k']),
OPENSSL_RAW_DATA,
pack('C*', ...$document->getIv())
)
: $clearContent;
try {
$response = $this
->client
->request(
Request::METHOD_PUT,
$this
->tempUrlGenerator
->generate(
Request::METHOD_PUT,
$document->getFilename()
)
->url,
[
'body' => $encryptedContent,
]
);
} catch (TransportExceptionInterface $exception) {
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
}
if ($response->getStatusCode() !== Response::HTTP_CREATED) {
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
}
}
private function hasKeysAndIv(StoredObject $storedObject): bool
{
return ([] !== $storedObject->getKeyInfos()) && ([] !== $storedObject->getIv());
}
}

View File

@ -0,0 +1,34 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
interface StoredObjectManagerInterface
{
/**
* Get the content of a StoredObject.
*
* @param StoredObject $document The document.
*
* @return string The retrieved content in clear.
*/
public function read(StoredObject $document): string;
/**
* Set the content of a StoredObject.
*
* @param StoredObject $document The document.
* @param $clearContent The content to store in clear.
*/
public function write(StoredObject $document, string $clearContent): void;
}

View File

@ -0,0 +1,183 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\DocStoreBundle\Tests;
use ChampsLibres\AsyncUploaderBundle\TempUrl\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\DocStoreBundle\Service\StoredObjectManager;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Generator;
use PHPUnit\Framework\TestCase;
use stdClass;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @internal
*
* @covers \Chill\DocStoreBundle\Service\StoredObjectManager
*/
final class StoredObjectManagerTest extends TestCase
{
public function getDataProvider(): Generator
{
/* HAPPY SCENARIO */
// Encrypted object
yield [
(new StoredObject())
->setFilename('encrypted.txt')
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')])
->setIv(unpack('C*', 'abcdefghijklmnop')),
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
'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())
->setFilename('error_during_http_request.txt')
->setKeyInfos(['k' => base64_encode('S9NIHMaFHOWzLPez3jZOIHBaNfBrMQUR5zvqBz6kme8')])
->setIv(unpack('C*', 'abcdefghijklmnop')),
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear
StoredObjectManagerException::class,
];
// 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')),
hex2bin('741237d255fd4f7eddaaa9058912a84caae28a41b10b34d4e3e3abe41d3b9b47cb0dd8f22c3c883d4f0e9defa75ff662'), // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear
StoredObjectManagerException::class,
];
// 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')),
'WRONG_ENCODED_VALUE', // Binary encoded string
'The quick brown fox jumps over the lazy dog', // clear
StoredObjectManagerException::class,
];
}
/**
* @dataProvider getDataProvider
*/
public function testRead(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null)
{
if (null !== $exceptionClass) {
$this->expectException($exceptionClass);
}
$storedObjectManager = $this->getSubject($storedObject, $encodedContent);
self::assertEquals($clearContent, $storedObjectManager->read($storedObject));
}
/**
* @dataProvider getDataProvider
*/
public function testWrite(StoredObject $storedObject, string $encodedContent, string $clearContent, ?string $exceptionClass = null)
{
if (null !== $exceptionClass) {
$this->expectException($exceptionClass);
}
$storedObjectManager = $this->getSubject($storedObject, $encodedContent);
$storedObjectManager->write($storedObject, $clearContent);
self::assertEquals($clearContent, $storedObjectManager->read($storedObject));
}
private function getHttpClient(string $encodedContent): HttpClientInterface
{
$callback = static function ($method, $url, $options) use ($encodedContent) {
if (Request::METHOD_GET === $method) {
switch ($url) {
case 'https://example.com/non-encrypted.txt':
case 'https://example.com/encrypted.txt':
return new MockResponse($encodedContent, ['http_code' => 200]);
case 'https://example.com/error_during_http_request.txt':
return new TransportException('error_during_http_request.txt');
case 'https://example.com/invalid_statuscode.txt':
return new MockResponse($encodedContent, ['http_code' => 404]);
}
}
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 MockHttpClient($callback);
}
private function getSubject(StoredObject $storedObject, string $encodedContent): StoredObjectManagerInterface
{
return new StoredObjectManager(
$this->getHttpClient($encodedContent),
$this->getTempUrlGenerator($storedObject)
);
}
private function getTempUrlGenerator(StoredObject $storedObject): TempUrlGeneratorInterface
{
$response = new stdClass();
$response->url = $storedObject->getFilename();
$tempUrlGenerator = $this
->getMockBuilder(TempUrlGeneratorInterface::class)
->getMock();
$tempUrlGenerator
->method('generate')
->withAnyParameters()
->willReturn($response);
return $tempUrlGenerator;
}
}