ldc-file-storage-service/tests/Unit/Lib/FileSanitizerImplTest.php

366 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace Uwil\Storage\Tests\Unit\Lib;
use Illuminate\Http\UploadedFile;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Contracts\MimeTypeChecker;
use Uwil\Storage\Exceptions\BadUploadedFile;
use Uwil\Storage\Lib\FileSanitizerImpl;
use Uwil\Storage\Lib\ValueObjects\SanitizedFile;
covers(FileSanitizerImpl::class);
/**
* @param array<string> $allowedExtensions
*/
function createConfig(array $allowedExtensions = ['jpg', 'png', 'pdf', 'txt']): StorageServiceConfig
{
return new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local'],
laravelExistingDisks: [(object) ['disk' => 'local', 'driver' => 'local']],
allowedExtensions: $allowedExtensions
);
}
describe('FileSanitizerImpl sanitizeFileName', function (): void {
it('cleans directory paths and replaces invalid characters with underscore', function (): void {
// GIVEN
$config = createConfig();
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
// WHEN
$cleaned = $sanitizer->sanitizeFileName('path/to/my special @file #1.pdf');
// THEN
expect($cleaned)->toBe('my_special_file_1.pdf');
});
it('strips leading dots and dashes from the filename', function (string $input, string $expected): void {
// GIVEN
$config = createConfig();
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
// WHEN
$cleaned = $sanitizer->sanitizeFileName($input);
// THEN
expect($cleaned)->toBe($expected);
})->with([
'leading dots' => ['...hidden.txt', 'hidden.txt'],
'leading dashes' => ['---my-file.png', 'my-file.png'],
'mixed leading' => ['.-.-avatar.jpg', 'avatar.jpg'],
]);
it('compresses multiple consecutive underscores and multiple dots', function (): void {
// GIVEN
$config = createConfig();
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
// WHEN
$cleaned = $sanitizer->sanitizeFileName('my___file....name.pdf');
// THEN
expect($cleaned)->toBe('my_file.name.pdf');
});
it('truncates filename exceeding maximum 255 characters while preserving extension', function (): void {
// GIVEN
$config = createConfig();
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
$longBaseName = str_repeat('a', 300);
$originalName = $longBaseName . '.pdf';
// WHEN
$cleaned = $sanitizer->sanitizeFileName($originalName);
// THEN
expect(strlen($cleaned))->toBeLessThanOrEqual(255)
->and($cleaned)->toEndWith('.pdf');
});
});
describe('FileSanitizerImpl sanitizeFile', function (): void {
it('successfully sanitizes an SplFileObject', function (): void {
// GIVEN
$tempFile = tempnam(sys_get_temp_dir(), 'test_spl_') . '.txt';
file_put_contents($tempFile, 'Sample content');
$config = createConfig(['txt']);
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$mimeChecker->shouldReceive('detectMimeType')->with($tempFile)->andReturn('text/plain');
$mimeChecker->shouldReceive('isMimeAllowedForExtension')->with('txt', 'text/plain')->andReturn(true);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
$splFile = new \SplFileObject($tempFile);
try {
// WHEN
$result = $sanitizer->sanitizeFile($splFile);
// THEN
expect($result)->toBeInstanceOf(SanitizedFile::class)
->and($result->mimeType)->toBe('text/plain')
->and($result->size)->toBe(strlen('Sample content'));
} finally {
unset($splFile);
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
});
it('throws BadUploadedFile if file is not allowed by extension', function (): void {
// GIVEN
$tempFile = tempnam(sys_get_temp_dir(), 'test_bad_ext_') . '.exe';
file_put_contents($tempFile, 'content');
$config = createConfig(['pdf']);
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
$splFile = new \SplFileObject($tempFile);
try {
// WHEN
$action = fn () => $sanitizer->sanitizeFile($splFile);
// THEN
expect($action)->toThrow(BadUploadedFile::class, 'is not allowed for file');
} finally {
unset($splFile);
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
});
it('throws BadUploadedFile if detected MIME is disallowed for extension', function (): void {
// GIVEN
$tempFile = tempnam(sys_get_temp_dir(), 'test_bad_mime_') . '.txt';
file_put_contents($tempFile, 'content');
$config = createConfig(['txt']);
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$mimeChecker->shouldReceive('detectMimeType')->with($tempFile)->andReturn('application/x-executable');
$mimeChecker->shouldReceive('isMimeAllowedForExtension')->andReturn(false);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
$splFile = new \SplFileObject($tempFile);
try {
// WHEN
$action = fn () => $sanitizer->sanitizeFile($splFile);
// THEN
expect($action)->toThrow(BadUploadedFile::class, 'is forbidden or invalid for extension');
} finally {
unset($splFile);
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
});
it('throws BadUploadedFile if file cannot be read', function (): void {
// GIVEN
$tempFile = tempnam(sys_get_temp_dir(), 'test_deleted_') . '.txt';
file_put_contents($tempFile, 'data');
$splFile = new \SplFileObject($tempFile);
unlink($tempFile);
$config = createConfig(['txt']);
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
// WHEN
$action = fn () => $sanitizer->sanitizeFile($splFile);
// THEN
expect($action)->toThrow(BadUploadedFile::class, 'Unable to read file:');
});
});
describe('FileSanitizerImpl sanitizeUploaded', function (): void {
it('successfully sanitizes a valid UploadedFile', function (): void {
// GIVEN
$tempFile = tempnam(sys_get_temp_dir(), 'test_upload_');
file_put_contents($tempFile, 'Uploaded document content');
$config = createConfig(['pdf']);
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$mimeChecker->shouldReceive('detectMimeType')->with($tempFile)->andReturn('application/pdf');
$mimeChecker->shouldReceive('isMimeAllowedForExtension')->with('pdf', 'application/pdf')->andReturn(true);
$uploadedFile = new UploadedFile($tempFile, 'Invoice #100.pdf', 'application/pdf', null, true);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
try {
// WHEN
$result = $sanitizer->sanitizeUploaded($uploadedFile);
// THEN
expect($result)->toBeInstanceOf(SanitizedFile::class)
->and($result->originalName)->toBe('Invoice #100.pdf')
->and($result->sanitizedName)->toBe('Invoice_100.pdf')
->and($result->mimeType)->toBe('application/pdf');
} finally {
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
});
it('throws BadUploadedFile if uploaded file is unreadable', function (): void {
// GIVEN
$tempFile = tempnam(sys_get_temp_dir(), 'test_del_up_') . '.pdf';
file_put_contents($tempFile, 'data');
$uploadedFile = new UploadedFile($tempFile, 'test.pdf', null, null, true);
unlink($tempFile);
$config = createConfig(['pdf']);
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
// WHEN
$action = fn () => $sanitizer->sanitizeUploaded($uploadedFile);
// THEN
expect($action)->toThrow(BadUploadedFile::class, 'Unable to read uploaded file:');
});
it('throws BadUploadedFile if UploadedFile reports error', function (): void {
// GIVEN
$config = createConfig();
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
$uploadedFile = new UploadedFile('/tmp/fake.txt', 'fake.txt', null, UPLOAD_ERR_INI_SIZE, false);
// WHEN
$action = fn () => $sanitizer->sanitizeUploaded($uploadedFile);
// THEN
expect($action)->toThrow(BadUploadedFile::class, 'Uploaded file is not valid.');
});
});
describe('FileSanitizerImpl fileIsValid', function (): void {
it('returns false when uploaded file path does not exist or is unreadable', function (): void {
// GIVEN
$tempFile = tempnam(sys_get_temp_dir(), 'test_del_valid_') . '.txt';
file_put_contents($tempFile, 'data');
$uploadedFile = new UploadedFile($tempFile, 'test.txt', null, null, true);
unlink($tempFile);
$config = createConfig(['txt']);
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
// WHEN
$isValid = $sanitizer->fileIsValid($uploadedFile);
// THEN
expect($isValid)->toBeFalse();
});
it('returns true when UploadedFile has allowed extension and allowed MIME', function (): void {
// GIVEN
$tempFile = tempnam(sys_get_temp_dir(), 'test_valid_');
file_put_contents($tempFile, 'Valid file');
$config = createConfig(['txt']);
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$mimeChecker->shouldReceive('detectMimeType')->with($tempFile)->andReturn('text/plain');
$mimeChecker->shouldReceive('isMimeAllowedForExtension')->with('txt', 'text/plain')->andReturn(true);
$uploadedFile = new UploadedFile($tempFile, 'notes.txt', 'text/plain', null, true);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
try {
// WHEN
$isValid = $sanitizer->fileIsValid($uploadedFile);
// THEN
expect($isValid)->toBeTrue();
} finally {
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
});
it('returns false when UploadedFile reports upload error', function (): void {
// GIVEN
$config = createConfig();
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
$uploadedFile = new UploadedFile('/tmp/fake.txt', 'fake.txt', null, UPLOAD_ERR_NO_FILE, false);
// WHEN
$isValid = $sanitizer->fileIsValid($uploadedFile);
// THEN
expect($isValid)->toBeFalse();
});
it('returns false when extension is not allowed', function (): void {
// GIVEN
$tempFile = tempnam(sys_get_temp_dir(), 'test_not_allowed_ext_');
file_put_contents($tempFile, 'Executable');
$config = createConfig(['pdf']);
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$uploadedFile = new UploadedFile($tempFile, 'app.exe', null, null, true);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
try {
// WHEN
$isValid = $sanitizer->fileIsValid($uploadedFile);
// THEN
expect($isValid)->toBeFalse();
} finally {
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
});
it('returns false when MIME type is not allowed for extension', function (): void {
// GIVEN
$tempFile = tempnam(sys_get_temp_dir(), 'test_mime_mismatch_');
file_put_contents($tempFile, 'Fake PDF');
$config = createConfig(['pdf']);
$mimeChecker = \Mockery::mock(MimeTypeChecker::class);
$mimeChecker->shouldReceive('detectMimeType')->with($tempFile)->andReturn('application/x-php');
$mimeChecker->shouldReceive('isMimeAllowedForExtension')->with('pdf', 'application/x-php')->andReturn(false);
$uploadedFile = new UploadedFile($tempFile, 'fake.pdf', null, null, true);
$sanitizer = new FileSanitizerImpl($config, $mimeChecker);
try {
// WHEN
$isValid = $sanitizer->fileIsValid($uploadedFile);
// THEN
expect($isValid)->toBeFalse();
} finally {
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
});
});