tests/Unit/MultipartStorageTest.php
Rama main · réplica de lectura del repositorio original.
<?php
declare(strict_types=1);
/**
* Copyright 2026 codaipro — Lucky Yaduvanshi (https://luckyyaduvanshi.in)
* Original source: https://github.com/Luckyyaduvanshiofficial/php-s3
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
namespace PhpS3\Tests\Unit;
use PhpS3\S3\Exception\S3Exception;
use PhpS3\S3\Handlers\MultipartHandler;
use PhpS3\Storage\LocalFilesystemStorage;
use PhpS3\Storage\StagedObject;
use PHPUnit\Framework\TestCase;
/** Multipart part staging/assembly against a throwaway data root. */
final class MultipartStorageTest extends TestCase
{
private string $root;
private LocalFilesystemStorage $storage;
protected function setUp(): void
{
$this->root = sys_get_temp_dir() . '/php_s3_st_' . bin2hex(random_bytes(6));
mkdir($this->root, 0750, true);
$this->storage = new LocalFilesystemStorage($this->root);
}
protected function tearDown(): void
{
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($it as $file) {
$file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname());
}
@rmdir($this->root);
}
public function testCommitAndOpenPartRoundTrip(): void
{
$uploadId = str_repeat('a', 32);
$staged = $this->stage('hello part');
$this->storage->commitPart($uploadId, 3, $staged);
$fh = $this->storage->openPart($uploadId, 3);
self::assertSame('hello part', stream_get_contents($fh));
fclose($fh);
}
public function testCommitPartOverwritesSamePartNumber(): void
{
$uploadId = str_repeat('b', 32);
$this->storage->commitPart($uploadId, 1, $this->stage('first'));
$this->storage->commitPart($uploadId, 1, $this->stage('second'));
$fh = $this->storage->openPart($uploadId, 1);
self::assertSame('second', stream_get_contents($fh));
fclose($fh);
}
public function testAssemblePartsConcatenatesInOrderAndHashes(): void
{
$uploadId = str_repeat('c', 32);
foreach ([1 => 'AAA', 2 => 'BBB', 3 => 'CC'] as $n => $data) {
$this->storage->commitPart($uploadId, $n, $this->stage($data));
}
$assembled = $this->storage->assembleParts($uploadId, [1, 2, 3], 8);
self::assertSame(8, $assembled->size);
self::assertSame('AAABBBCC', file_get_contents($assembled->tmpPath));
self::assertSame(md5('AAABBBCC'), $assembled->md5);
self::assertSame(hash('sha256', 'AAABBBCC'), $assembled->sha256);
$this->storage->discard($assembled);
}
public function testAssemblePartsWithoutAllPartsThrowsInvalidPart(): void
{
$uploadId = str_repeat('d', 32);
$this->storage->commitPart($uploadId, 1, $this->stage('x'));
try {
$this->storage->assembleParts($uploadId, [1, 2], 2);
self::fail('expected InvalidPart');
} catch (S3Exception $e) {
self::assertSame('InvalidPart', $e->errorCode);
}
}
public function testAssembleSizeMismatchThrowsIncompleteBody(): void
{
$uploadId = str_repeat('e', 32);
$this->storage->commitPart($uploadId, 1, $this->stage('xyz'));
try {
$this->storage->assembleParts($uploadId, [1], 99);
self::fail('expected IncompleteBody');
} catch (S3Exception $e) {
self::assertSame('IncompleteBody', $e->errorCode);
}
}
public function testMissingPartFileThrowsInvalidPart(): void
{
try {
$this->storage->openPart(str_repeat('f', 32), 1);
self::fail('expected InvalidPart');
} catch (S3Exception $e) {
self::assertSame('InvalidPart', $e->errorCode);
}
}
public function testTraversalUploadIdRejected(): void
{
foreach (['../../etc', str_repeat('A', 32), str_repeat('a', 31), ''] as $bad) {
try {
$this->storage->commitPart($bad, 1, $this->stage('x'));
self::fail("expected NoSuchUpload for '{$bad}'");
} catch (S3Exception $e) {
self::assertSame('NoSuchUpload', $e->errorCode);
}
}
}
public function testPartNumberOutOfRangeRejected(): void
{
foreach ([0, 10001] as $bad) {
try {
$this->storage->commitPart(str_repeat('a', 32), $bad, $this->stage('x'));
self::fail("expected NoSuchUpload for part {$bad}");
} catch (S3Exception $e) {
self::assertSame('NoSuchUpload', $e->errorCode);
}
}
}
public function testDeletePartsIsIdempotent(): void
{
$uploadId = str_repeat('a', 32);
$this->storage->commitPart($uploadId, 1, $this->stage('x'));
$this->storage->deleteParts($uploadId);
$this->storage->deleteParts($uploadId); // no error
self::assertDirectoryDoesNotExist($this->root . '/parts/' . $uploadId);
}
public function testCompositeEtagMatchesS3Algorithm(): void
{
// md5(binary md5-of-empty + binary md5-of-"test") . '-2'
self::assertSame(
'f666c301a43456b66b9f3a47f696d391-2',
MultipartHandler::compositeEtag([
'd41d8cd98f00b204e9800998ecf8427e',
'098f6bcd4621d373cade4e832627b4f6',
]),
);
}
public function testCompositeEtagRejectsCorruptPartEtag(): void
{
$this->expectException(S3Exception::class);
MultipartHandler::compositeEtag(['zzzz']);
}
private function stage(string $data): StagedObject
{
$fh = fopen('php://temp', 'r+b');
fwrite($fh, $data);
rewind($fh);
return $this->storage->stage($fh, strlen($data));
}
}