tests/Unit/OperationResolverTest.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\Exception\UnsupportedOperation;
use PhpS3\S3\OperationResolver;
use PhpS3\S3\S3Operation;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class OperationResolverTest extends TestCase
{
/* ---------------------------------------------------------- parsePath */
public function testServiceScope(): void
{
$r = php_s3_test_request('GET', '/');
self::assertSame(
['scope' => 'service', 'bucket' => null, 'key' => null, 'is_admin' => false],
OperationResolver::parsePath($r),
);
}
public function testBucketScope(): void
{
$p = OperationResolver::parsePath(php_s3_test_request('GET', '/my-bucket'));
self::assertSame('bucket', $p['scope']);
self::assertSame('my-bucket', $p['bucket']);
self::assertNull($p['key']);
}
public function testObjectScopeWithSlashInKey(): void
{
$p = OperationResolver::parsePath(php_s3_test_request('GET', '/b/photos/2024/img.jpg'));
self::assertSame('object', $p['scope']);
self::assertSame('b', $p['bucket']);
self::assertSame('photos/2024/img.jpg', $p['key']);
}
public function testTrailingSlashIsBucketScopeNotEmptyObjectKey(): void
{
// aws-sdk-php sends ListObjectsV2 as GET /bucket/?list-type=2
$p = OperationResolver::parsePath(php_s3_test_request('GET', '/my-bucket/'));
self::assertSame('bucket', $p['scope']);
self::assertSame('my-bucket', $p['bucket']);
self::assertNull($p['key']);
}
public function testObjectKeyIsSegmentDecodedNotGlobally(): void
{
// '+' inside a key must survive (S3 decodes %2B, never '+')
$p = OperationResolver::parsePath(php_s3_test_request('GET', '/b/a%2Bb'));
self::assertSame('a+b', $p['key']);
$p = OperationResolver::parsePath(php_s3_test_request('GET', '/b/a%20b'));
self::assertSame('a b', $p['key']);
}
public function testReservedAdminRoutes(): void
{
foreach (['/_health', '/_admin', '/_admin/keys'] as $uri) {
$p = OperationResolver::parsePath(php_s3_test_request('GET', $uri));
self::assertTrue($p['is_admin'], $uri);
}
}
public function testVirtualHostedStyleMovesBucketFromHost(): void
{
$r = php_s3_test_request('GET', '/obj.txt', ['host' => 'mybucket.s3.test.local']);
$p = OperationResolver::parsePath($r, 's3.test.local');
self::assertSame('object', $p['scope']);
self::assertSame('mybucket', $p['bucket']);
self::assertSame('obj.txt', $p['key']);
}
public function testVirtualHostedStyleRejectedForAmbiguousHost(): void
{
// candidate label contains a dot → not treated as a bucket; first
// path segment becomes the bucket instead.
$r = php_s3_test_request('GET', '/x', ['host' => 'foo.bar.s3.test.local']);
$p = OperationResolver::parsePath($r, 's3.test.local');
self::assertSame('bucket', $p['scope']);
self::assertSame('x', $p['bucket']);
}
/* ----------------------------------------------------------- resolve */
/** @return array{0: \PhpS3\Http\Request, 1: array{scope:string,bucket:?string,key:?string,is_admin:bool}} */
private function parsed(string $method, string $uri, array $headers = []): array
{
$r = php_s3_test_request($method, $uri, $headers);
return [$r, OperationResolver::parsePath($r)];
}
#[DataProvider('resolveMatrix')]
public function testResolveMatrix(string $method, string $uri, S3Operation $expected, array $headers = []): void
{
[$r, $p] = $this->parsed($method, $uri, $headers);
self::assertSame($expected, OperationResolver::resolve($r, $p));
}
/** @return iterable<string, array{string, string, S3Operation, array}> */
public static function resolveMatrix(): iterable
{
yield 'ListBuckets' => ['GET', '/', S3Operation::ServiceListBuckets];
yield 'CreateBucket' => ['PUT', '/my-bucket', S3Operation::BucketCreate];
yield 'HeadBucket' => ['HEAD', '/my-bucket', S3Operation::BucketHead];
yield 'DeleteBucket' => ['DELETE', '/my-bucket', S3Operation::BucketDelete];
yield 'ListObjectsV1' => ['GET', '/my-bucket', S3Operation::ListObjectsV1];
yield 'ListObjectsV2' => ['GET', '/my-bucket?list-type=2', S3Operation::ListObjectsV2];
yield 'ListObjectsV2 trailing slash' => ['GET', '/my-bucket/?list-type=2', S3Operation::ListObjectsV2];
yield 'CreateBucket trailing slash' => ['PUT', '/my-bucket/', S3Operation::BucketCreate];
yield 'GetBucketLocation' => ['GET', '/my-bucket?location', S3Operation::BucketLocation];
yield 'PutObject' => ['PUT', '/b/k.txt', S3Operation::ObjectPut];
yield 'CopyObject' => ['PUT', '/b/k.txt', S3Operation::ObjectCopy, ['x-amz-copy-source' => '/src/k.txt']];
yield 'GetObject' => ['GET', '/b/k.txt', S3Operation::ObjectGet];
yield 'HeadObject' => ['HEAD', '/b/k.txt', S3Operation::ObjectHead];
yield 'DeleteObject' => ['DELETE', '/b/k.txt', S3Operation::ObjectDelete];
yield 'CreateMultipartUpload' => ['POST', '/b/k.txt?uploads', S3Operation::MultipartCreate];
yield 'UploadPart' => ['PUT', '/b/k.txt?partNumber=1&uploadId=abc', S3Operation::MultipartUploadPart];
yield 'ListParts GET' => ['GET', '/b/k.txt?uploadId=abc', S3Operation::MultipartListParts];
yield 'ListParts HEAD' => ['HEAD', '/b/k.txt?uploadId=abc', S3Operation::MultipartListParts];
yield 'CompleteMultipartUpload' => ['POST', '/b/k.txt?uploadId=abc', S3Operation::MultipartComplete];
yield 'AbortMultipartUpload' => ['DELETE', '/b/k.txt?uploadId=abc', S3Operation::MultipartAbort];
yield 'ListMultipartUploads' => ['GET', '/b?uploads', S3Operation::MultipartListUploads];
yield 'DeleteObjects' => ['POST', '/b?delete', S3Operation::ObjectsDelete];
}
public function testPresignedQueryStillResolvesGetObjectAuthLayerRejects(): void
{
// OperationResolver maps by shape only; Authenticator verifies the
// X-Amz-Signature query parameter (covered in AuthenticatorTest).
[$r, $p] = $this->parsed('GET', '/b/k.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&X-Amz-Signature=deadbeef');
self::assertSame(S3Operation::ObjectGet, OperationResolver::resolve($r, $p));
}
public function testUnresolvableMethodThrows405(): void
{
$this->expectException(UnsupportedOperation::class);
[$r, $p] = $this->parsed('PATCH', '/b/k.txt');
OperationResolver::resolve($r, $p);
}
/**
* Unimplemented subresources must never fall through to a neighbouring
* operation: DeleteBucketLifecycle mapping to DeleteBucket would destroy
* data, PutObjectAcl mapping to PutObject would overwrite the object.
*/
#[DataProvider('unsupportedSubresourceProvider')]
public function testUnsupportedSubresourcesNeverMapToDestructiveOperations(string $method, string $uri): void
{
[$r, $p] = $this->parsed($method, $uri);
try {
OperationResolver::resolve($r, $p);
self::fail("Expected NotImplemented for {$method} {$uri}");
} catch (S3Exception $e) {
self::assertSame('NotImplemented', $e->errorCode);
}
}
/** @return iterable<string, array{string, string}> */
public static function unsupportedSubresourceProvider(): iterable
{
yield 'DeleteBucketLifecycle must not delete the bucket' => ['DELETE', '/my-bucket?lifecycle'];
yield 'PutBucketAcl must not create a bucket' => ['PUT', '/my-bucket?acl'];
yield 'PutBucketVersioning must not create a bucket' => ['PUT', '/my-bucket?versioning'];
yield 'GetBucketAcl must not list objects' => ['GET', '/my-bucket?acl'];
yield 'GetBucketVersions must not list objects' => ['GET', '/my-bucket?versions'];
yield 'PutObjectAcl must not overwrite the object' => ['PUT', '/b/k.txt?acl'];
yield 'GetObjectAcl must not leak object bytes' => ['GET', '/b/k.txt?acl'];
yield 'GetObjectTorrent is unsupported' => ['GET', '/b/k.txt?torrent'];
yield 'GetObject partNumber is unsupported' => ['GET', '/b/k.txt?partNumber=2'];
yield 'HeadObject partNumber is unsupported' => ['HEAD', '/b/k.txt?partNumber=2'];
yield 'PUT with ?uploads is invalid' => ['PUT', '/b/k.txt?uploads'];
yield 'DELETE with bucket ?uploads is invalid' => ['DELETE', '/my-bucket?uploads'];
yield 'GET with bucket ?delete is invalid' => ['GET', '/my-bucket?delete'];
yield 'PUT with ?location is invalid' => ['PUT', '/my-bucket?location'];
}
public function testVirtualHostedBucketDetectionIsCaseInsensitive(): void
{
$r = php_s3_test_request('GET', '/obj.txt', ['host' => 'MyBucket.S3.Test.Local']);
$p = OperationResolver::parsePath($r, 's3.test.local');
self::assertSame('object', $p['scope']);
self::assertSame('mybucket', $p['bucket']);
self::assertSame('obj.txt', $p['key']);
}
public function testAdminScopeNeverResolvesToS3Operation(): void
{
$this->expectException(\LogicException::class);
$r = php_s3_test_request('GET', '/_admin');
OperationResolver::resolve($r, OperationResolver::parsePath($r));
}
}