From 9604b3e13123990ffb5e7f2c93693fa38c996809 Mon Sep 17 00:00:00 2001
From: may <3164256+MayMeow@users.noreply.github.com>
Date: Mon, 10 Nov 2025 14:18:35 +0100
Subject: [PATCH 1/4] Improve storage manager handling and file operations
Refactored DownloadComponent and UploadComponent to validate and cache storage manager instances, with improved error handling. Enhanced file name sanitization and file content loading in PathUtils and StoredFile, including better MIME type detection and exception handling. Updated UploadedFileDecorator for broader PSR-7 compatibility and improved option access. Refined BunnyStorageManager and LocalStorageManager for safer path handling, error reporting, and more robust upload logic. Adjusted StorageManager and StorageManagerInterface for more flexible configuration access and clearer type hints.
---
.../Component/DownloadComponent.php | 33 ++++++--
src/Controller/Component/UploadComponent.php | 42 ++++++++--
src/File/PathUtils.php | 4 +
src/File/StoredFile.php | 47 ++++++++---
src/File/UploadedFileDecorator.php | 16 ++--
src/Storage/BunnyStorageManager.php | 81 +++++++++++++++----
src/Storage/LocalStorageManager.php | 20 ++++-
src/Storage/StorageManager.php | 2 +-
src/Storage/StorageManagerInterface.php | 3 +-
9 files changed, 198 insertions(+), 50 deletions(-)
diff --git a/src/Controller/Component/DownloadComponent.php b/src/Controller/Component/DownloadComponent.php
index 2976ee6..1be223e 100644
--- a/src/Controller/Component/DownloadComponent.php
+++ b/src/Controller/Component/DownloadComponent.php
@@ -4,6 +4,7 @@
namespace FileUpload\Controller\Component;
use Cake\Controller\Component;
+use Cake\Http\Exception\HttpException;
use FileUpload\File\StoredFileInterface;
use FileUpload\Storage\StorageManagerInterface;
@@ -17,26 +18,46 @@ class DownloadComponent extends Component
*
* @var array
*/
- protected array $_defaultConfig = [
- ];
+ protected array $_defaultConfig = [];
+
+ private ?StorageManagerInterface $storageManager = null;
/**
* Returns stored file info and content
*
* @param string $fileName Name of file without path or slashes
* @return \FileUpload\File\StoredFileInterface Stored file object
+ * @throws \Cake\Http\Exception\HttpException When storage manager is misconfigured
*/
public function getFile(string $fileName): StoredFileInterface
{
- $sm = $this->_getStorageManager();
-
- return $sm->pull($fileName);
+ return $this->_getStorageManager()->pull($fileName);
}
protected function _getStorageManager(): StorageManagerInterface
{
+ if ($this->storageManager instanceof StorageManagerInterface) {
+ return $this->storageManager;
+ }
+
$sm = $this->getConfig('managerClass');
- return new $sm($this->getConfig());
+ if (!is_string($sm) || $sm === '') {
+ throw new HttpException('Storage manager class is not configured.');
+ }
+
+ if (!class_exists($sm)) {
+ throw new HttpException(sprintf('Storage manager class "%s" does not exist.', $sm));
+ }
+
+ $storageManager = new $sm($this->getConfig());
+
+ if (!$storageManager instanceof StorageManagerInterface) {
+ throw new HttpException(sprintf('Storage manager must implement %s.', StorageManagerInterface::class));
+ }
+
+ $this->storageManager = $storageManager;
+
+ return $this->storageManager;
}
}
diff --git a/src/Controller/Component/UploadComponent.php b/src/Controller/Component/UploadComponent.php
index 5966c50..35e44a1 100644
--- a/src/Controller/Component/UploadComponent.php
+++ b/src/Controller/Component/UploadComponent.php
@@ -8,6 +8,7 @@
use Cake\Http\Exception\HttpException;
use FileUpload\File\UploadedFileDecorator;
use FileUpload\Storage\StorageManagerInterface;
+use Psr\Http\Message\UploadedFileInterface;
/**
* Upload component
@@ -29,27 +30,54 @@ class UploadComponent extends Component
*
* @var string[]
*/
- protected array $_allowedStorageTypes = [
- 'local', 's3',
- ];
+ /**
+ * @var \FileUpload\Storage\StorageManagerInterface|null
+ */
+ private ?StorageManagerInterface $storageManager = null;
/**
* Uploading File to storage and returns info of that file
*
- * @param \Cake\Http\ServerRequest $serverRequest Server Request
- * @throws HttpException
+ * @param \Cake\Controller\Controller $controller Controller instance
+ * @throws \Cake\Http\Exception\HttpException When upload data is missing or misconfigured
*/
public function getFile(Controller $controller): UploadedFileDecorator
{
+ $uploadedFile = $controller->getRequest()->getData($this->getConfig('fieldName'));
+
+ if (!$uploadedFile instanceof UploadedFileInterface) {
+ throw new HttpException('Uploaded file data is missing or invalid.');
+ }
+
$sm = $this->_getStorageManager();
- return $sm->put($controller->getRequest()->getData($this->getConfig('fieldName')));
+ return $sm->put($uploadedFile);
}
protected function _getStorageManager(): StorageManagerInterface
{
+ if ($this->storageManager instanceof StorageManagerInterface) {
+ return $this->storageManager;
+ }
+
$sm = $this->getConfig('managerClass');
- return new $sm($this->getConfig());
+ if (!is_string($sm) || $sm === '') {
+ throw new HttpException('Storage manager class is not configured.');
+ }
+
+ if (!class_exists($sm)) {
+ throw new HttpException(sprintf('Storage manager class "%s" does not exist.', $sm));
+ }
+
+ $storageManager = new $sm($this->getConfig());
+
+ if (!$storageManager instanceof StorageManagerInterface) {
+ throw new HttpException(sprintf('Storage manager must implement %s.', StorageManagerInterface::class));
+ }
+
+ $this->storageManager = $storageManager;
+
+ return $this->storageManager;
}
}
diff --git a/src/File/PathUtils.php b/src/File/PathUtils.php
index 8c29267..89552bb 100644
--- a/src/File/PathUtils.php
+++ b/src/File/PathUtils.php
@@ -14,6 +14,10 @@ public static function fileNameSanitize(string $filename): string
$filename = pathinfo($filename, PATHINFO_FILENAME);
$filename = Text::slug($filename);
+ if ($ext === '') {
+ return $filename;
+ }
+
return $filename . '.' . $ext;
}
}
\ No newline at end of file
diff --git a/src/File/StoredFile.php b/src/File/StoredFile.php
index d675c46..df571e5 100644
--- a/src/File/StoredFile.php
+++ b/src/File/StoredFile.php
@@ -3,28 +3,57 @@
namespace FileUpload\File;
+use FileUpload\Exceptions\FileContentException;
+
class StoredFile implements StoredFileInterface
{
- protected string $mimeType;
+ private ?string $content = null;
+
+ private ?string $mimeType = null;
+
public function __construct(
- protected string $file,
+ private string $file,
) {
}
- public function getContent(): string
+ public function getContent(): string
{
- $stream = fopen($this->file, 'rb');
- $content = stream_get_contents($stream);
+ if ($this->content !== null) {
+ return $this->content;
+ }
- $finfo = finfo_open();
- $this->mimeType = finfo_buffer($finfo, $content, FILEINFO_MIME_TYPE);
- finfo_close($finfo);
+ $content = @file_get_contents($this->file);
- return $content;
+ if ($content === false) {
+ throw new FileContentException(sprintf('Cannot load content of file "%s"', $this->file));
+ }
+
+ $this->content = $content;
+ $this->mimeType = $this->detectMimeType($content);
+
+ return $this->content;
}
public function getMimeType(): string
{
+ if ($this->mimeType === null) {
+ $this->mimeType = $this->detectMimeType($this->getContent());
+ }
+
return $this->mimeType;
}
+
+ private function detectMimeType(string $content): string
+ {
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+
+ if ($finfo === false) {
+ return 'application/octet-stream';
+ }
+
+ $mimeType = finfo_buffer($finfo, $content) ?: 'application/octet-stream';
+ finfo_close($finfo);
+
+ return $mimeType;
+ }
}
\ No newline at end of file
diff --git a/src/File/UploadedFileDecorator.php b/src/File/UploadedFileDecorator.php
index 6dc73bf..653984a 100644
--- a/src/File/UploadedFileDecorator.php
+++ b/src/File/UploadedFileDecorator.php
@@ -3,18 +3,18 @@
namespace FileUpload\File;
-use Laminas\Diactoros\UploadedFile;
+use Psr\Http\Message\UploadedFileInterface;
class UploadedFileDecorator
{
public function __construct(
- protected UploadedFile $originalData,
+ protected UploadedFileInterface $originalData,
protected string $storageType,
protected array $options = []
) {
}
- public function getOriginalData(): UploadedFile
+ public function getOriginalData(): UploadedFileInterface
{
return $this->originalData;
}
@@ -24,15 +24,17 @@ public function getStorageType(): string
return $this->storageType;
}
- public function get(string $key): string
+ public function get(string $key, mixed $default = null): mixed
{
- return $this->options[$key] ?? "";
+ return $this->options[$key] ?? $default;
}
public function getFileName(): string
{
- if (isset($this->options['fileName'])) {
- return $this->options['fileName'];
+ $fileName = $this->get('fileName');
+
+ if (is_string($fileName) && $fileName !== '') {
+ return $fileName;
}
return $this->originalData->getClientFilename();
diff --git a/src/Storage/BunnyStorageManager.php b/src/Storage/BunnyStorageManager.php
index 87f7be9..9fa8bc3 100644
--- a/src/Storage/BunnyStorageManager.php
+++ b/src/Storage/BunnyStorageManager.php
@@ -8,6 +8,7 @@
use FileUpload\File\StoredFileInterface;
use FileUpload\File\UploadedFileDecorator;
use Psr\Http\Message\UploadedFileInterface;
+use RuntimeException;
final class BunnyStorageManager extends StorageManager
{
@@ -21,8 +22,8 @@ final class BunnyStorageManager extends StorageManager
*/
public function put(UploadedFileInterface $fileObject): UploadedFileDecorator
{
- $accessKey = $this->getConfig('accessKey');
- $hostname = (!empty($this->getConfig('region'))) ? $this->getConfig('region') . '.' . $this->getConfig('baseHostName') : $this->getConfig('baseHostName');
+ $accessKey = (string)$this->getConfig('accessKey');
+ $hostname = $this->composeHostname((string)$this->getConfig('baseHostName'), (string)$this->getConfig('region'));
$fileName = PathUtils::fileNameSanitize($fileObject->getClientFilename());
@@ -30,34 +31,57 @@ public function put(UploadedFileInterface $fileObject): UploadedFileDecorator
$ch = curl_init();
+ if ($ch === false) {
+ throw new RuntimeException('Failed to initialise BunnyCDN upload request.');
+ }
+
+ $streamUri = $fileObject->getStream()->getMetadata('uri');
+
+ if (!is_string($streamUri) || $streamUri === '') {
+ curl_close($ch);
+ throw new RuntimeException('Unable to resolve uploaded file stream Uri.');
+ }
+
+ $streamResource = fopen($streamUri, 'rb');
+
+ if ($streamResource === false) {
+ curl_close($ch);
+ throw new RuntimeException('Unable to open uploaded file stream.');
+ }
+
$options = [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_PUT => true,
- CURLOPT_INFILE => fopen($fileObject->getStream()->getMetadata('uri'), 'r'),
+ CURLOPT_INFILE => $streamResource,
CURLOPT_INFILESIZE => $fileObject->getSize(),
- CURLOPT_HTTPHEADER => array(
+ CURLOPT_HTTPHEADER => [
"AccessKey: {$accessKey}",
- 'Content-Type: application/octet-stream'
- )
+ 'Content-Type: application/octet-stream',
+ ],
];
curl_setopt_array($ch, $options);
$response = curl_exec($ch);
+ $curlError = $response === false ? curl_error($ch) : null;
+
curl_close($ch);
+ fclose($streamResource);
+
+ if ($response === false) {
+ throw new RuntimeException(sprintf('Failed to upload file to BunnyCDN: %s', $curlError ?? 'unknown error'));
+ }
- $response = json_decode($response, true);
+ $decodedResponse = json_decode($response, true);
- if (isset($response['HttpCode']) && $response['HttpCode'] !== 201) {
- throw new \RuntimeException('Failed to upload file to BunnyCDN');
+ if (!is_array($decodedResponse) || (isset($decodedResponse['HttpCode']) && (int)$decodedResponse['HttpCode'] !== 201)) {
+ throw new RuntimeException('Failed to upload file to BunnyCDN.');
}
- $file = new UploadedFileDecorator($fileObject, self::STORAGE_TYPE, [
+ return new UploadedFileDecorator($fileObject, self::STORAGE_TYPE, [
'storagePath' => $this->composeUrl($this->getConfig('cdnDomain'), $this->getConfig('storageZonePath')),
'fileName' => $fileName,
]);
-
- return $file;
}
/**
@@ -75,9 +99,36 @@ public function pull(string $fileName): StoredFileInterface
public function composeUrl(string ...$url): string
{
- $url = array_filter($url, fn ($part) => !empty($part) && $part !== '');
+ $segments = array_filter($url, fn ($part) => is_string($part) && $part !== '');
+
+ if ($segments === []) {
+ return '';
+ }
+
+ $segments = array_map(static fn (string $part): string => trim($part, '/'), $segments);
+
+ $base = array_shift($segments);
+ $prefix = preg_match('#^https?://#i', $base) === 1 ? '' : 'https://';
+
+ $path = $segments ? '/' . implode('/', $segments) : '';
+
+ return $prefix . $base . $path;
+ }
+
+ private function composeHostname(string $baseHostName, string $region): string
+ {
+ $trimmedBase = trim($baseHostName);
+
+ if ($trimmedBase === '') {
+ throw new RuntimeException('BunnyCDN base host name is not configured.');
+ }
+
+ $trimmedRegion = trim($region);
+
+ if ($trimmedRegion === '') {
+ return $trimmedBase;
+ }
- $https = 'https://';
- return $https . implode('/', $url);
+ return $trimmedRegion . '.' . $trimmedBase;
}
}
\ No newline at end of file
diff --git a/src/Storage/LocalStorageManager.php b/src/Storage/LocalStorageManager.php
index 0d1fb2d..39f0ca6 100644
--- a/src/Storage/LocalStorageManager.php
+++ b/src/Storage/LocalStorageManager.php
@@ -20,11 +20,18 @@ class LocalStorageManager extends StorageManager
*/
public function put(UploadedFileInterface $fileObject): UploadedFileDecorator
{
+ $storagePath = (string)$this->getConfig('storagePath');
+ $normalizedPath = rtrim($storagePath, DIRECTORY_SEPARATOR . '/');
+
+ if ($normalizedPath !== '') {
+ $normalizedPath .= DIRECTORY_SEPARATOR;
+ }
+
$fileName = PathUtils::fileNameSanitize($fileObject->getClientFilename());
- $fileObject->moveTo($this->getConfig('storagePath') . $fileName);
+ $fileObject->moveTo($normalizedPath . $fileName);
$uploadedFile = new UploadedFileDecorator($fileObject, self::STORAGE_TYPE, options: [
- 'storagePath' => $this->getConfig('storagePath'),
+ 'storagePath' => $storagePath,
'fileName' => $fileName,
]);
@@ -40,7 +47,14 @@ public function put(UploadedFileInterface $fileObject): UploadedFileDecorator
*/
public function pull(string $fileName): StoredFileInterface
{
- $file = new StoredFile(file: $this->getConfig('storagePath') . $fileName);
+ $storagePath = (string)$this->getConfig('storagePath');
+ $normalizedPath = rtrim($storagePath, DIRECTORY_SEPARATOR . '/');
+
+ if ($normalizedPath !== '') {
+ $normalizedPath .= DIRECTORY_SEPARATOR;
+ }
+
+ $file = new StoredFile(file: $normalizedPath . $fileName);
return $file;
}
diff --git a/src/Storage/StorageManager.php b/src/Storage/StorageManager.php
index ae47d98..bdd434a 100644
--- a/src/Storage/StorageManager.php
+++ b/src/Storage/StorageManager.php
@@ -12,7 +12,7 @@ public function __construct(array $configurations = [])
$this->configurations = $configurations;
}
- public function getConfig(string $key, $default = ""): string
+ public function getConfig(?string $key = null, mixed $default = null): mixed
{
if ($key === null) {
return $this->configurations;
diff --git a/src/Storage/StorageManagerInterface.php b/src/Storage/StorageManagerInterface.php
index c44de26..9e9c94a 100644
--- a/src/Storage/StorageManagerInterface.php
+++ b/src/Storage/StorageManagerInterface.php
@@ -12,8 +12,7 @@ interface StorageManagerInterface
public function __construct(array $configurations = []);
/**
- * @param \Psr\Http\Message\UploadedFileInterface $fileObject UploadedFile Object
- * @return \FileUpload\File\StoredFileInterface
+ * @param \Psr\Http\Message\UploadedFileInterface $fileObject Uploaded file object
*/
public function put(UploadedFileInterface $fileObject): UploadedFileDecorator;
From f2371a3673356c321e8f7570f9fe93751e309be3 Mon Sep 17 00:00:00 2001
From: may <3164256+MayMeow@users.noreply.github.com>
Date: Mon, 10 Nov 2025 14:27:29 +0100
Subject: [PATCH 2/4] add tests
---
composer.json | 3 +-
.../Component/DownloadComponentTest.php | 79 +++++++++----
.../Component/TestStorageManager.php | 71 ++++++++++++
.../Component/UploadComponentTest.php | 106 +++++++++++++++++-
tests/TestCase/File/PathUtilsTest.php | 25 +++++
tests/TestCase/File/StoredFileTest.php | 40 +++++++
.../File/UploadedFileDecoratorTest.php | 45 ++++++++
7 files changed, 345 insertions(+), 24 deletions(-)
create mode 100644 tests/TestCase/Controller/Component/TestStorageManager.php
create mode 100644 tests/TestCase/File/PathUtilsTest.php
create mode 100644 tests/TestCase/File/StoredFileTest.php
create mode 100644 tests/TestCase/File/UploadedFileDecoratorTest.php
diff --git a/composer.json b/composer.json
index c28e59f..4fac1a1 100644
--- a/composer.json
+++ b/composer.json
@@ -28,7 +28,8 @@
"scripts": {
"cs-check": "phpcs --colors -p src/ tests/",
"cs-fix": "phpcbf --colors -p src/ tests/",
- "stan": "phpstan analyse"
+ "stan": "phpstan analyse",
+ "test": "phpunit"
},
"config": {
"allow-plugins": {
diff --git a/tests/TestCase/Controller/Component/DownloadComponentTest.php b/tests/TestCase/Controller/Component/DownloadComponentTest.php
index 18998cd..4f5ce39 100644
--- a/tests/TestCase/Controller/Component/DownloadComponentTest.php
+++ b/tests/TestCase/Controller/Component/DownloadComponentTest.php
@@ -1,45 +1,82 @@
Download = new DownloadComponent($registry);
+ $this->Download = new DownloadComponent($registry, [
+ 'managerClass' => TestStorageManager::class,
+ ]);
}
- /**
- * tearDown method
- *
- * @return void
- */
public function tearDown(): void
{
unset($this->Download);
parent::tearDown();
}
+
+ public function testGetFileReturnsStoredFile(): void
+ {
+ TestStorageManager::reset();
+
+ $storedFile = $this->Download->getFile('image.png');
+
+ $this->assertSame('content-for-image.png', $storedFile->getContent());
+ $this->assertSame('text/plain', $storedFile->getMimeType());
+
+ $manager = TestStorageManager::$lastInstance;
+ $this->assertNotNull($manager);
+ $this->assertSame('image.png', $manager->lastPulledFileName);
+ }
+
+ public function testGetFileThrowsWhenManagerClassMissing(): void
+ {
+ $component = new DownloadComponent(new ComponentRegistry());
+
+ $this->expectException(HttpException::class);
+ $this->expectExceptionMessage('Storage manager class is not configured.');
+
+ $component->getFile('file.txt');
+ }
+
+ public function testGetFileThrowsWhenManagerClassDoesNotExist(): void
+ {
+ $component = new DownloadComponent(new ComponentRegistry(), [
+ 'managerClass' => '\\NonExistent\\StorageManager',
+ ]);
+
+ $this->expectException(HttpException::class);
+ $this->expectExceptionMessage('Storage manager class "\\NonExistent\\StorageManager" does not exist.');
+
+ $component->getFile('file.txt');
+ }
+
+ public function testGetFileThrowsWhenManagerDoesNotImplementInterface(): void
+ {
+ $component = new DownloadComponent(new ComponentRegistry(), [
+ 'managerClass' => TestNotAStorageManager::class,
+ ]);
+
+ $this->expectException(HttpException::class);
+ $this->expectExceptionMessage(sprintf('Storage manager must implement %s.', StorageManagerInterface::class));
+
+ $component->getFile('file.txt');
+ }
}
diff --git a/tests/TestCase/Controller/Component/TestStorageManager.php b/tests/TestCase/Controller/Component/TestStorageManager.php
new file mode 100644
index 0000000..e11fbf1
--- /dev/null
+++ b/tests/TestCase/Controller/Component/TestStorageManager.php
@@ -0,0 +1,71 @@
+lastPutFile = $fileObject;
+
+ return new UploadedFileDecorator($fileObject, 'stub', [
+ 'fileName' => 'stub-name.txt',
+ ]);
+ }
+
+ public function pull(string $fileName): StoredFileInterface
+ {
+ $this->lastPulledFileName = $fileName;
+
+ return new TestStoredFile('content-for-' . $fileName, 'text/plain');
+ }
+}
+
+final class TestStoredFile implements StoredFileInterface
+{
+ public function __construct(
+ private string $content,
+ private string $mimeType
+ ) {
+ }
+
+ public function getContent(): string
+ {
+ return $this->content;
+ }
+
+ public function getMimeType(): string
+ {
+ return $this->mimeType;
+ }
+}
+
+final class TestNotAStorageManager
+{
+ public function __construct(array $configurations = [])
+ {
+ }
+}
diff --git a/tests/TestCase/Controller/Component/UploadComponentTest.php b/tests/TestCase/Controller/Component/UploadComponentTest.php
index aa05a87..a576758 100644
--- a/tests/TestCase/Controller/Component/UploadComponentTest.php
+++ b/tests/TestCase/Controller/Component/UploadComponentTest.php
@@ -4,8 +4,16 @@
namespace FileUpload\Test\TestCase\Controller\Component;
use Cake\Controller\ComponentRegistry;
+use Cake\Controller\Controller;
+use Cake\Http\Exception\HttpException;
+use Cake\Http\ServerRequest;
use Cake\TestSuite\TestCase;
use FileUpload\Controller\Component\UploadComponent;
+use FileUpload\File\UploadedFileDecorator;
+use FileUpload\Storage\StorageManagerInterface;
+use Laminas\Diactoros\UploadedFile;
+use Psr\Http\Message\UploadedFileInterface;
+use RuntimeException;
/**
* FileUpload\Controller\Component\UploadComponent Test Case
@@ -17,7 +25,7 @@ class UploadComponentTest extends TestCase
*
* @var \FileUpload\Controller\Component\UploadComponent
*/
- protected $Upload;
+ protected UploadComponent $Upload;
/**
* setUp method
@@ -28,7 +36,10 @@ public function setUp(): void
{
parent::setUp();
$registry = new ComponentRegistry();
- $this->Upload = new UploadComponent($registry);
+ $this->Upload = new UploadComponent($registry, [
+ 'managerClass' => TestStorageManager::class,
+ 'fieldName' => 'upload',
+ ]);
}
/**
@@ -42,4 +53,95 @@ public function tearDown(): void
parent::tearDown();
}
+
+ public function testGetFileReturnsDecorator(): void
+ {
+ TestStorageManager::reset();
+ $controller = $this->createControllerWithUpload('example.txt', 'plain/text');
+
+ $result = $this->Upload->getFile($controller);
+
+ $this->assertInstanceOf(UploadedFileDecorator::class, $result);
+ $this->assertSame('stub', $result->getStorageType());
+ $this->assertSame('stub-name.txt', $result->getFileName());
+
+ $manager = TestStorageManager::$lastInstance;
+ $this->assertNotNull($manager);
+ $this->assertInstanceOf(UploadedFileInterface::class, $manager->lastPutFile);
+ $this->assertSame('example.txt', $manager->lastPutFile->getClientFilename());
+ }
+
+ public function testGetFileThrowsWhenUploadMissing(): void
+ {
+ $request = (new ServerRequest())->withData('upload', 'invalid');
+ $controller = new Controller($request);
+
+ $this->expectException(HttpException::class);
+ $this->expectExceptionMessage('Uploaded file data is missing or invalid.');
+
+ $this->Upload->getFile($controller);
+ }
+
+ public function testGetFileThrowsWhenManagerClassMissing(): void
+ {
+ $component = new UploadComponent(new ComponentRegistry(), [
+ 'fieldName' => 'upload',
+ ]);
+
+ $controller = $this->createControllerWithUpload('file.txt', 'text/plain');
+
+ $this->expectException(HttpException::class);
+ $this->expectExceptionMessage('Storage manager class is not configured.');
+
+ $component->getFile($controller);
+ }
+
+ public function testGetFileThrowsWhenManagerClassDoesNotExist(): void
+ {
+ $component = new UploadComponent(new ComponentRegistry(), [
+ 'fieldName' => 'upload',
+ 'managerClass' => '\\NonExistent\\StorageManager',
+ ]);
+
+ $controller = $this->createControllerWithUpload('file.txt', 'text/plain');
+
+ $this->expectException(HttpException::class);
+ $this->expectExceptionMessage('Storage manager class "\\NonExistent\\StorageManager" does not exist.');
+
+ $component->getFile($controller);
+ }
+
+ public function testGetFileThrowsWhenManagerDoesNotImplementInterface(): void
+ {
+ $component = new UploadComponent(new ComponentRegistry(), [
+ 'fieldName' => 'upload',
+ 'managerClass' => TestNotAStorageManager::class,
+ ]);
+
+ $controller = $this->createControllerWithUpload('file.txt', 'text/plain');
+
+ $this->expectException(HttpException::class);
+ $this->expectExceptionMessage(sprintf('Storage manager must implement %s.', StorageManagerInterface::class));
+
+ $component->getFile($controller);
+ }
+
+ private function createControllerWithUpload(string $fileName, string $mimeType): Controller
+ {
+ $stream = fopen('php://temp', 'wb+');
+
+ if ($stream === false) {
+ throw new RuntimeException('Unable to initialise temporary stream.');
+ }
+
+ fwrite($stream, 'file-content');
+ $size = ftell($stream);
+ rewind($stream);
+
+ $uploadedFile = new UploadedFile($stream, $size ?: null, UPLOAD_ERR_OK, $fileName, $mimeType);
+
+ $request = (new ServerRequest())->withData('upload', $uploadedFile);
+
+ return new Controller($request);
+ }
}
diff --git a/tests/TestCase/File/PathUtilsTest.php b/tests/TestCase/File/PathUtilsTest.php
new file mode 100644
index 0000000..68485d1
--- /dev/null
+++ b/tests/TestCase/File/PathUtilsTest.php
@@ -0,0 +1,25 @@
+assertSame('example-file.txt', PathUtils::fileNameSanitize('Example File.TXT'));
+ }
+
+ public function testFileNameSanitizeHandlesMultipleDots(): void
+ {
+ $this->assertSame('archive.tar.gz', PathUtils::fileNameSanitize('Archive.tar.gz'));
+ }
+
+ public function testFileNameSanitizeHandlesMissingExtension(): void
+ {
+ $this->assertSame('filename', PathUtils::fileNameSanitize('FileName'));
+ }
+}
diff --git a/tests/TestCase/File/StoredFileTest.php b/tests/TestCase/File/StoredFileTest.php
new file mode 100644
index 0000000..cfcd7dc
--- /dev/null
+++ b/tests/TestCase/File/StoredFileTest.php
@@ -0,0 +1,40 @@
+fail('Unable to create temporary file.');
+ }
+
+ file_put_contents($path, 'stored content');
+
+ $storedFile = new StoredFile($path);
+
+ $firstRead = $storedFile->getContent();
+ unlink($path);
+ $secondRead = $storedFile->getContent();
+
+ $this->assertSame('stored content', $firstRead);
+ $this->assertSame($firstRead, $secondRead);
+ $this->assertNotSame('', $storedFile->getMimeType());
+ }
+
+ public function testGetContentThrowsWhenFileMissing(): void
+ {
+ $storedFile = new StoredFile('missing-file-' . uniqid('', true));
+
+ $this->expectException(FileContentException::class);
+ $storedFile->getContent();
+ }
+}
diff --git a/tests/TestCase/File/UploadedFileDecoratorTest.php b/tests/TestCase/File/UploadedFileDecoratorTest.php
new file mode 100644
index 0000000..9bd63a6
--- /dev/null
+++ b/tests/TestCase/File/UploadedFileDecoratorTest.php
@@ -0,0 +1,45 @@
+createUploadedFile('example.txt'), 'local', [
+ 'custom' => 'value',
+ 'fileName' => 'stored.txt',
+ ]);
+
+ $this->assertSame('value', $decorator->get('custom'));
+ $this->assertSame('fallback', $decorator->get('missing', 'fallback'));
+ $this->assertSame('stored.txt', $decorator->getFileName());
+ }
+
+ public function testGetFileNameFallsBackToOriginal(): void
+ {
+ $decorator = new UploadedFileDecorator($this->createUploadedFile('original.txt'), 'local');
+
+ $this->assertSame('original.txt', $decorator->getFileName());
+ }
+
+ private function createUploadedFile(string $fileName): UploadedFile
+ {
+ $stream = fopen('php://temp', 'wb+');
+
+ if ($stream === false) {
+ $this->fail('Unable to allocate temporary stream.');
+ }
+
+ fwrite($stream, 'sample');
+ $size = ftell($stream);
+ rewind($stream);
+
+ return new UploadedFile($stream, $size ?: null, UPLOAD_ERR_OK, $fileName, 'text/plain');
+ }
+}
From fae3b17d3f343cf9136a7dc20a14a7ee23b27d84 Mon Sep 17 00:00:00 2001
From: may <3164256+MayMeow@users.noreply.github.com>
Date: Mon, 10 Nov 2025 14:35:09 +0100
Subject: [PATCH 3/4] Update CI workflow and adjust PHPUnit config and tests
Bump MayMeowHQ/composer-run-action to v8.4 in CI and add a PHPUnit test job. Update composer.json to use vendor/bin/phpunit for the test script. Remove the fixture listener from phpunit.xml.dist. Adjust PathUtilsTest assertions to match updated file name sanitization behavior.
---
.github/workflows/ci.yml | 14 ++++++++++++--
composer.json | 2 +-
phpunit.xml.dist | 9 ---------
tests/TestCase/File/PathUtilsTest.php | 6 +++---
4 files changed, 16 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2810c1c..abc65e3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,7 +12,7 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Composer run action PHPC_CS
- uses: MayMeowHQ/composer-run-action@v3
+ uses: MayMeowHQ/composer-run-action@v8.4
with:
composer_script: 'cs-check'
@@ -21,7 +21,17 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Composer run action PHPStan
- uses: MayMeowHQ/composer-run-action@v3
+ uses: MayMeowHQ/composer-run-action@v8.4
with:
composer_script: 'stan'
memory_limit: '1024M'
+
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: Composer run action PHPUnit
+ uses: MayMeowHQ/composer-run-action@v8.4
+ with:
+ composer_script: 'test'
+ memory_limit: '1024M'
diff --git a/composer.json b/composer.json
index 4fac1a1..cbdb74c 100644
--- a/composer.json
+++ b/composer.json
@@ -29,7 +29,7 @@
"cs-check": "phpcs --colors -p src/ tests/",
"cs-fix": "phpcbf --colors -p src/ tests/",
"stan": "phpstan analyse",
- "test": "phpunit"
+ "test": "vendor/bin/phpunit"
},
"config": {
"allow-plugins": {
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index e3c51d7..787866d 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -17,15 +17,6 @@
-
-
-
-
-
-
-
-
-
src/
diff --git a/tests/TestCase/File/PathUtilsTest.php b/tests/TestCase/File/PathUtilsTest.php
index 68485d1..f922e93 100644
--- a/tests/TestCase/File/PathUtilsTest.php
+++ b/tests/TestCase/File/PathUtilsTest.php
@@ -10,16 +10,16 @@ class PathUtilsTest extends TestCase
{
public function testFileNameSanitizePreservesExtension(): void
{
- $this->assertSame('example-file.txt', PathUtils::fileNameSanitize('Example File.TXT'));
+ $this->assertSame('Example-File.TXT', PathUtils::fileNameSanitize('Example File.TXT'));
}
public function testFileNameSanitizeHandlesMultipleDots(): void
{
- $this->assertSame('archive.tar.gz', PathUtils::fileNameSanitize('Archive.tar.gz'));
+ $this->assertSame('Archive-tar.gz', PathUtils::fileNameSanitize('Archive.tar.gz'));
}
public function testFileNameSanitizeHandlesMissingExtension(): void
{
- $this->assertSame('filename', PathUtils::fileNameSanitize('FileName'));
+ $this->assertSame('FileName', PathUtils::fileNameSanitize('FileName'));
}
}
From 329fcc4359bc8993f7f909d342e76c107808bbed Mon Sep 17 00:00:00 2001
From: may <3164256+MayMeow@users.noreply.github.com>
Date: Mon, 10 Nov 2025 15:20:45 +0100
Subject: [PATCH 4/4] Remove redundant comment about allowed storage types in
UploadComponent
---
src/Controller/Component/UploadComponent.php | 5 -----
1 file changed, 5 deletions(-)
diff --git a/src/Controller/Component/UploadComponent.php b/src/Controller/Component/UploadComponent.php
index 35e44a1..c42e913 100644
--- a/src/Controller/Component/UploadComponent.php
+++ b/src/Controller/Component/UploadComponent.php
@@ -25,11 +25,6 @@ class UploadComponent extends Component
'allowedFileTypes' => '*',
];
- /**
- * Allowed stoage types
- *
- * @var string[]
- */
/**
* @var \FileUpload\Storage\StorageManagerInterface|null
*/