Files
intotheeast-com-content/plugins/api/tests/Unit/Controllers/HandlesMediaUploadsTest.php
T

278 lines
9.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Controllers;
use Grav\Common\Config\Config;
use Grav\Plugin\Api\Controllers\MediaController;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Services\UploadFieldSettings;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversTrait;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
use ReflectionMethod;
/**
* Unit coverage for the shared upload pipeline (HandlesMediaUploads). The trait
* is the security-critical, storage-agnostic core reused by both page media and
* flex-object media, so it carries the validation guarantees that matter:
* dangerous extensions are blocked, traversal filenames are rejected, the size
* cap is enforced, and a clean file lands in the target folder.
*
* Exercised through MediaController since it uses the trait; the methods under
* test are storage-agnostic, so the same behavior applies to FlexApiController.
*/
#[CoversTrait(\Grav\Plugin\Api\Controllers\HandlesMediaUploads::class)]
class HandlesMediaUploadsTest extends TestCase
{
private string $tempDir;
private MediaController $controller;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/grav_api_media_trait_' . uniqid();
mkdir($this->tempDir, 0775, true);
$config = new Config([
'security' => ['uploads_dangerous_extensions' => ['php', 'phtml', 'phar', 'js', 'html']],
]);
// createMockGrav installs the Grav singleton the base controller reads.
$grav = TestHelper::createMockGrav(['config' => $config]);
$this->controller = new MediaController($grav, $config);
}
protected function tearDown(): void
{
$this->rmrf($this->tempDir);
}
#[Test]
public function a_clean_file_lands_in_the_target_folder(): void
{
$file = new TraitTestUploadedFile('avatar.png', 'binary-png-data');
$name = $this->invoke('processUploadedFile', $file, $this->tempDir);
self::assertSame('avatar.png', $name);
self::assertFileExists($this->tempDir . '/avatar.png');
self::assertSame('binary-png-data', file_get_contents($this->tempDir . '/avatar.png'));
}
#[Test]
public function dangerous_extension_is_rejected_and_no_file_is_written(): void
{
$file = new TraitTestUploadedFile('shell.php', '<?php evil();');
try {
$this->invoke('processUploadedFile', $file, $this->tempDir);
self::fail('Expected ValidationException for a .php upload.');
} catch (ValidationException) {
self::assertFileDoesNotExist($this->tempDir . '/shell.php');
}
}
#[Test]
public function extensionless_file_is_rejected(): void
{
$file = new TraitTestUploadedFile('README', 'no extension');
$this->expectException(ValidationException::class);
$this->invoke('processUploadedFile', $file, $this->tempDir);
}
#[Test]
public function traversal_filename_is_rejected(): void
{
$file = new TraitTestUploadedFile('../../evil.png', 'png');
// basename() strips the path, but the resulting name still must not be
// a dotfile or contain traversal markers — assert nothing escapes.
try {
$this->invoke('processUploadedFile', $file, $this->tempDir);
} catch (ValidationException) {
// acceptable — rejected outright
}
self::assertFileDoesNotExist(dirname($this->tempDir) . '/evil.png');
}
#[Test]
public function dotfile_is_rejected(): void
{
$file = new TraitTestUploadedFile('.htaccess', 'deny');
$this->expectException(ValidationException::class);
$this->invoke('processUploadedFile', $file, $this->tempDir);
}
#[Test]
public function oversize_file_is_rejected(): void
{
// Reports a size beyond the 64 MB cap without writing 64 MB to disk.
$file = new TraitTestUploadedFile('big.png', 'x', 67_108_864 + 1);
$this->expectException(ValidationException::class);
$this->invoke('processUploadedFile', $file, $this->tempDir);
}
#[Test]
public function random_name_replaces_the_filename_but_keeps_the_extension(): void
{
$file = new TraitTestUploadedFile('My Photo.PNG', 'png-bytes');
$settings = UploadFieldSettings::fromParams(['random_name' => '1']);
$name = $this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
self::assertNotSame('My Photo.PNG', $name);
self::assertMatchesRegularExpression('/^[a-z0-9]{15}\.png$/', $name);
self::assertFileExists($this->tempDir . '/' . $name);
self::assertFileDoesNotExist($this->tempDir . '/My Photo.PNG');
}
#[Test]
public function avoid_overwriting_prefixes_a_conflicting_filename(): void
{
// Pre-seed a colliding file so the conflict branch fires.
file_put_contents($this->tempDir . '/logo.png', 'existing');
$file = new TraitTestUploadedFile('logo.png', 'new-bytes');
$settings = UploadFieldSettings::fromParams(['avoid_overwriting' => true]);
$name = $this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
self::assertMatchesRegularExpression('/^\d{14}-logo\.png$/', $name);
self::assertSame('existing', file_get_contents($this->tempDir . '/logo.png'));
self::assertSame('new-bytes', file_get_contents($this->tempDir . '/' . $name));
}
#[Test]
public function accept_allowlist_rejects_a_non_matching_file(): void
{
$file = new TraitTestUploadedFile('notes.txt', 'text');
$settings = UploadFieldSettings::fromParams(['accept' => 'image/*']);
$this->expectException(ValidationException::class);
$this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
}
#[Test]
public function accept_allowlist_admits_a_matching_extension(): void
{
$file = new TraitTestUploadedFile('photo.png', 'png');
$settings = UploadFieldSettings::fromParams(['accept' => '.png,.jpg']);
$name = $this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
self::assertSame('photo.png', $name);
self::assertFileExists($this->tempDir . '/photo.png');
}
#[Test]
public function per_field_filesize_limit_is_enforced_under_the_hard_cap(): void
{
// 2 MB file against a 1 MB per-field limit — well under the 64 MB cap.
$file = new TraitTestUploadedFile('big.png', 'x', 2 * 1_048_576);
$settings = UploadFieldSettings::fromParams(['filesize' => 1]);
$this->expectException(ValidationException::class);
$this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
}
#[Test]
public function random_name_cannot_smuggle_a_dangerous_extension(): void
{
// The extension floor runs before random_name, which preserves the
// extension — so a .php upload is still rejected even with randomizing.
$file = new TraitTestUploadedFile('shell.php', '<?php evil();');
$settings = UploadFieldSettings::fromParams(['random_name' => '1']);
$this->expectException(ValidationException::class);
$this->invoke('processUploadedFile', $file, $this->tempDir, $settings);
}
#[Test]
public function nested_uploaded_files_are_flattened(): void
{
$a = new TraitTestUploadedFile('a.png', 'a');
$b = new TraitTestUploadedFile('b.png', 'b');
$c = new TraitTestUploadedFile('c.png', 'c');
// Mirrors PSR-7 nesting: files[gallery][] alongside files[avatar].
$flat = $this->invoke('flattenUploadedFiles', ['avatar' => $a, 'gallery' => [$b, $c]]);
self::assertCount(3, $flat);
self::assertContainsOnlyInstancesOf(UploadedFileInterface::class, $flat);
}
private function invoke(string $method, mixed ...$args): mixed
{
$ref = new ReflectionMethod($this->controller, $method);
return $ref->invoke($this->controller, ...$args);
}
private function rmrf(string $path): void
{
if (is_file($path) || is_link($path)) {
unlink($path);
return;
}
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$this->rmrf($path . '/' . $item);
}
rmdir($path);
}
}
final class TraitTestUploadedFile implements UploadedFileInterface
{
private readonly string $source;
private bool $moved = false;
public function __construct(
private readonly string $filename,
string $contents,
private readonly ?int $reportedSize = null,
) {
$this->source = tempnam(sys_get_temp_dir(), 'grav_api_trait_upload_') ?: '';
file_put_contents($this->source, $contents);
}
public function getStream(): StreamInterface
{
throw new \RuntimeException('Not needed in tests.');
}
public function moveTo(string $targetPath): void
{
if ($this->moved) {
throw new \RuntimeException('File already moved.');
}
$dir = dirname($targetPath);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
rename($this->source, $targetPath);
$this->moved = true;
}
public function getSize(): ?int
{
return $this->reportedSize ?? (file_exists($this->source) ? filesize($this->source) : null);
}
public function getError(): int { return UPLOAD_ERR_OK; }
public function getClientFilename(): ?string { return $this->filename; }
public function getClientMediaType(): ?string { return 'application/octet-stream'; }
}