diff --git a/composer.json b/composer.json index d8489c39..6c145291 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "ext-sockets": "*", "amphp/amp": "^3.1.1", "amphp/http-server": "^3.4.4", + "amphp/http-server-form-parser": "^2.0.0", "amphp/websocket-client": "^2.0.2", "pestphp/pest": "^4.3.2", "pestphp/pest-plugin": "^4.0.0", diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index 97ae5fdb..7ae1ec80 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -23,6 +23,7 @@ use Pest\Browser\Exceptions\ServerNotFoundException; use Pest\Browser\Execution; use Pest\Browser\GlobalState; +use Pest\Browser\Http\ExtendedFormParser; use Pest\Browser\Playwright\Playwright; use Psr\Log\NullLogger; use Symfony\Component\Mime\MimeTypes; @@ -50,6 +51,11 @@ final class LaravelHttpServer implements HttpServer */ private ?Throwable $lastThrowable = null; + /** + * The multipart parser wrapper with upload validation behavior. + */ + private ExtendedFormParser $extendedFormParser; + /** * Creates a new laravel http server instance. */ @@ -57,7 +63,7 @@ public function __construct( public readonly string $host, public readonly int $port, ) { - // + $this->extendedFormParser = ExtendedFormParser::fromIni(); } /** @@ -69,6 +75,14 @@ public function __destruct() // $this->stop(); } + /** + * Overrides the multipart parser instance. + */ + public function setExtendedFormParser(ExtendedFormParser $extendedFormParser): void + { + $this->extendedFormParser = $extendedFormParser; + } + /** * Rewrite the given URL to match the server's host and port. */ @@ -239,22 +253,46 @@ private function handleRequest(AmpRequest $request): Response $contentType = $request->getHeader('content-type') ?? ''; $method = mb_strtoupper($request->getMethod()); - $rawBody = (string) $request->getBody(); $parameters = []; - if ($method !== 'GET' && str_starts_with(mb_strtolower($contentType), 'application/x-www-form-urlencoded')) { - parse_str($rawBody, $parameters); + $files = []; + + if ($method !== 'GET' && str_starts_with(mb_strtolower($contentType), 'multipart/form-data')) { + [$parameters, $files] = $this->parseMultipartFormData($request); + + $rawBody = ''; + } else { + $rawBody = (string) $request->getBody(); + + if ($method !== 'GET' && str_starts_with(mb_strtolower($contentType), 'application/x-www-form-urlencoded')) { + parse_str($rawBody, $parameters); + } } + $cookies = array_map(fn (RequestCookie $cookie): string => urldecode($cookie->getValue()), $request->getCookies()); $cookies = array_merge($cookies, test()->prepareCookiesForRequest()); // @phpstan-ignore-line /** @var array $serverVariables */ $serverVariables = test()->serverVariables(); // @phpstan-ignore-line + if ($contentType !== '') { + $serverVariables['CONTENT_TYPE'] = $contentType; + } + + $contentLength = $request->getHeader('content-length'); + if ($contentLength !== null && $contentLength !== '') { + $serverVariables['CONTENT_LENGTH'] = $contentLength; + } + + $contentMd5 = $request->getHeader('content-md5'); + if ($contentMd5 !== null && $contentMd5 !== '') { + $serverVariables['CONTENT_MD5'] = $contentMd5; + } + $symfonyRequest = Request::create( $absoluteUrl, $method, $parameters, $cookies, - [], // @TODO files... + $files, $serverVariables, $rawBody ); @@ -271,6 +309,9 @@ private function handleRequest(AmpRequest $request): Response $symfonyRequest->server->set('HTTP_HOST', $hostHeader); } + $superglobalState = $this->captureRequestSuperglobals(); + $symfonyRequest->overrideGlobals(); + $debug = config('app.debug'); try { @@ -283,6 +324,7 @@ private function handleRequest(AmpRequest $request): Response throw $e; } finally { config(['app.debug' => $debug]); + $this->restoreRequestSuperglobals($superglobalState); } $kernel->terminate($laravelRequest, $response); @@ -362,4 +404,40 @@ private function rewriteAssetUrl(string $content): string return str_replace($this->originalAssetUrl, $this->url(), $content); } + + /** + * Parse multipart form data and return request parameters and files. + * + * @return array{array, array} + */ + private function parseMultipartFormData(AmpRequest $request): array + { + return $this->extendedFormParser->parseMultipart($request); + } + + /** + * @return array{get: array, post: array, request: array, server: array, cookie: array} + */ + private function captureRequestSuperglobals(): array + { + return [ + 'get' => $_GET, + 'post' => $_POST, + 'request' => $_REQUEST, + 'server' => $_SERVER, + 'cookie' => $_COOKIE, + ]; + } + + /** + * @param array{get: array, post: array, request: array, server: array, cookie: array} $superglobalState + */ + private function restoreRequestSuperglobals(array $superglobalState): void + { + $_GET = $superglobalState['get']; + $_POST = $superglobalState['post']; + $_REQUEST = $superglobalState['request']; + $_SERVER = $superglobalState['server']; + $_COOKIE = $superglobalState['cookie']; + } } diff --git a/src/Http/ExtendedFormParser.php b/src/Http/ExtendedFormParser.php new file mode 100644 index 00000000..df662e78 --- /dev/null +++ b/src/Http/ExtendedFormParser.php @@ -0,0 +1,277 @@ +, array} + */ + public function parseMultipart(AmpRequest $request): array + { + $this->filesCount = 0; + $this->emptyCount = 0; + $this->maxFileSize = null; + + $form = Form::fromRequest($request); + $values = $form->getValues(); + + $maxFileSize = $values['MAX_FILE_SIZE'][0] ?? null; + if (is_string($maxFileSize) && is_numeric($maxFileSize)) { + $parsedMaxFileSize = (int) $maxFileSize; + + if ($parsedMaxFileSize > 0) { + $this->maxFileSize = $parsedMaxFileSize; + } + } + + $parameters = []; + foreach ($values as $field => $entries) { + foreach ($entries as $entry) { + $this->setFieldValue($parameters, $field, $entry); + } + } + + $files = []; + foreach ($form->getFiles() as $field => $entries) { + foreach ($entries as $entry) { + $parsedFile = $this->parseUploadedFile($entry); + + if ($parsedFile === null) { + continue; + } + + $this->setFieldValue($files, $field, $parsedFile); + } + } + + return [$parameters, $files]; + } + + private static function iniSizeToBytes(string $size): int|float + { + if (is_numeric($size)) { + return (int) $size; + } + + $suffix = mb_strtoupper(mb_substr($size, -1)); + $strippedSize = mb_substr($size, 0, -1); + + if (! is_numeric($strippedSize)) { + return 0; + } + + $value = (float) $strippedSize; + + return match ($suffix) { + 'K' => $value * 1024, + 'M' => $value * 1024 * 1024, + 'G' => $value * 1024 * 1024 * 1024, + 'T' => $value * 1024 * 1024 * 1024 * 1024, + default => (int) $size, + }; + } + + /** + * @return UploadedFile|array{name: string, type: string, tmp_name: string, error: int, size: int}|null + */ + private function parseUploadedFile(BufferedFile $entry): UploadedFile|array|null + { + $contents = $entry->getContents(); + $filename = $entry->getName(); + $contentType = $entry->getMimeType(); + + if ($contentType === '') { + $contentType = 'application/octet-stream'; + } + + $size = mb_strlen($contents, '8bit'); + + if ($size === 0 && $filename === '') { + if (++$this->emptyCount + $this->filesCount > $this->maxInputVars) { + return null; + } + + return [ + 'name' => $filename, + 'type' => $contentType, + 'tmp_name' => '', + 'error' => UPLOAD_ERR_NO_FILE, + 'size' => 0, + ]; + } + + if (++$this->filesCount > $this->maxFileUploads) { + return null; + } + + if ($size > $this->uploadMaxFilesize) { + return [ + 'name' => $filename, + 'type' => $contentType, + 'tmp_name' => '', + 'error' => UPLOAD_ERR_INI_SIZE, + 'size' => $size, + ]; + } + + if ($this->maxFileSize !== null && $size > $this->maxFileSize) { + return [ + 'name' => $filename, + 'type' => $contentType, + 'tmp_name' => '', + 'error' => UPLOAD_ERR_FORM_SIZE, + 'size' => $size, + ]; + } + + $tempPath = tempnam(sys_get_temp_dir(), 'pest-browser-upload-'); + if ($tempPath === false) { + return [ + 'name' => $filename, + 'type' => $contentType, + 'tmp_name' => '', + 'error' => UPLOAD_ERR_CANT_WRITE, + 'size' => $size, + ]; + } + + $written = file_put_contents($tempPath, $contents); + if ($written === false) { + return [ + 'name' => $filename, + 'type' => $contentType, + 'tmp_name' => '', + 'error' => UPLOAD_ERR_CANT_WRITE, + 'size' => $size, + ]; + } + + return new UploadedFile( + $tempPath, + $filename !== '' ? $filename : 'upload', + $contentType, + UPLOAD_ERR_OK, + true, + ); + } + + /** + * @param array $target + */ + private function setFieldValue(array &$target, string $field, mixed $value): void + { + $segments = $this->fieldSegments($field); + + if ($segments === []) { + return; + } + + $this->setNestedFieldValue($target, $segments, $value); + } + + /** + * @return list + */ + private function fieldSegments(string $field): array + { + if (! str_contains($field, '[')) { + return [$field]; + } + + $segments = []; + $head = mb_strstr($field, '[', true); + + if ($head !== false && $head !== '') { + $segments[] = $head; + } + + preg_match_all('/\[([^\]]*)\]/', $field, $matches); + + foreach ($matches[1] as $segment) { + $segments[] = $segment; + } + + return $segments; + } + + /** + * @param array $target + * @param list $segments + */ + private function setNestedFieldValue(array &$target, array $segments, mixed $value): void + { + $segment = array_shift($segments); + + if ($segment === null) { + return; + } + + if ($segments === []) { + if ($segment === '') { + $target[] = $value; + + return; + } + + $target[$segment] = $value; + + return; + } + + if ($segment === '') { + $target[] = []; + + $lastKey = array_key_last($target); + if (! is_array($target[$lastKey])) { + return; + } + + $this->setNestedFieldValue($target[$lastKey], $segments, $value); + + return; + } + + if (! isset($target[$segment]) || ! is_array($target[$segment])) { + $target[$segment] = []; + } + + $this->setNestedFieldValue($target[$segment], $segments, $value); + } +} diff --git a/tests/ArchTest.php b/tests/ArchTest.php index 2f5fe285..efb40497 100644 --- a/tests/ArchTest.php +++ b/tests/ArchTest.php @@ -9,5 +9,6 @@ Pest\Browser\Api\TestableLivewire::class, Pest\Browser\Cleanables\Livewire::class, Pest\Browser\Drivers\LaravelHttpServer::class, + Pest\Browser\Http\ExtendedFormParser::class, 'Workbench', ]); diff --git a/tests/Fixtures/example.pdf b/tests/Fixtures/example.pdf new file mode 100644 index 00000000..afa36062 Binary files /dev/null and b/tests/Fixtures/example.pdf differ diff --git a/tests/Fixtures/lorem-ipsum.txt b/tests/Fixtures/lorem-ipsum.txt new file mode 100644 index 00000000..f1727858 --- /dev/null +++ b/tests/Fixtures/lorem-ipsum.txt @@ -0,0 +1,9 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum sit amet bibendum dui. Phasellus viverra justo quam, eu sollicitudin felis commodo eu. Integer at elit commodo, egestas ipsum eu, vestibulum libero. Donec ut metus sed sapien lobortis ultricies sit amet ac quam. Proin vitae dui non tortor laoreet laoreet quis rutrum leo. Ut pharetra nec arcu gravida egestas. Duis eget nisl pharetra, fermentum sapien vel, tempus ligula. Sed fringilla feugiat gravida. Mauris efficitur eu metus non consectetur. Vestibulum faucibus lacus tellus, ut vulputate nisl dapibus nec. Nunc eleifend nisl urna, ut imperdiet velit tristique nec. Nunc vel lacus libero. Praesent et pretium metus. Interdum et malesuada fames ac ante ipsum primis in faucibus. + +In urna tellus, interdum in molestie nec, venenatis ut turpis. Aliquam erat volutpat. Nulla dictum turpis et tellus iaculis egestas. Proin velit neque, posuere sit amet sapien eget, dapibus pharetra velit. Morbi ultricies arcu et orci tempus accumsan. Maecenas ullamcorper, tortor nec finibus iaculis, sapien ipsum varius turpis, sit amet rutrum turpis libero cursus lorem. Nam bibendum lectus odio, ut sollicitudin justo gravida in. Integer aliquam, dolor eu tempor rutrum, enim nisl fringilla eros, a eleifend lectus est in turpis. + +Nulla interdum mi erat, cursus ultricies nisi vulputate non. Cras at neque ex. Sed sodales vehicula dolor, ut dignissim eros convallis et. Vestibulum ornare sagittis eleifend. Mauris fermentum orci est, sed venenatis ex tincidunt id. Morbi ut nisi sagittis, egestas quam in, ornare tellus. Mauris molestie tincidunt lorem ac volutpat. + +Duis fermentum, tellus vitae mollis finibus, diam augue pretium sapien, sed mattis urna lorem a leo. Sed quis enim finibus, finibus dui sit amet, luctus elit. Nullam velit enim, fermentum sit amet sem vitae, elementum volutpat justo. Integer porttitor tellus non dictum vulputate. Donec vulputate eu mi sit amet maximus. Donec egestas metus sit amet ante egestas, in suscipit elit dapibus. Proin magna massa, fringilla placerat facilisis vel, eleifend quis erat. Praesent ultrices lectus ac condimentum consectetur. + +Ut vel venenatis augue, nec luctus neque. Nullam commodo urna a felis tempor posuere. Duis nec nisl eget leo efficitur feugiat. Nulla elementum massa a sapien finibus fermentum. Quisque pretium ligula in sapien auctor, in rhoncus ex eleifend. Sed id bibendum orci, sed ultrices sem. Curabitur sollicitudin quam odio, dignissim lacinia dolor ultrices vel. diff --git a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php index 7491d6a6..524db35b 100644 --- a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php +++ b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; use function Pest\Laravel\withServerVariables; use function Pest\Laravel\withUnencryptedCookie; @@ -36,3 +37,70 @@ visit('/server-variables') ->assertSee('"test-server-key":"test value"'); }); + +it('restores original superglobals after request handling', function (): void { + $originalGet = $_GET; + $originalPost = $_POST; + $originalRequest = $_REQUEST; + $originalServer = $_SERVER; + $originalCookie = $_COOKIE; + + $_GET['__restore_probe_get'] = 'before-get'; + $_POST['__restore_probe_post'] = 'before-post'; + $_REQUEST['__restore_probe_request'] = 'before-request'; + $_SERVER['__RESTORE_PROBE_SERVER'] = 'before-server'; + $_COOKIE['__restore_probe_cookie'] = 'before-cookie'; + + try { + Route::get('/restore-superglobals', static fn () => response()->make(" + + + +
+ + + + +
+ + + ")->cookie('restore_cookie', 'cookie-value')); + + Route::post('/restore-superglobals', static fn (Request $request): array => [ + 'during_get' => $_GET['source'] ?? null, + 'during_post' => $_POST['name'] ?? null, + 'during_request_post' => $_REQUEST['name'] ?? null, + 'during_request_get' => $_REQUEST['source'] ?? null, + 'during_cookie' => $_COOKIE['restore_cookie'] ?? null, + 'during_server_probe' => $_SERVER['__RESTORE_PROBE_SERVER'] ?? null, + 'during_query_string' => $_SERVER['QUERY_STRING'] ?? null, + 'request_cookie' => $request->cookie('restore_cookie'), + ]); + + $page = visit('/restore-superglobals'); + + $page->fill('Your name', 'World'); + $page->click('Send'); + + $page->assertSee('"during_get":"query-value"') + ->assertSee('"during_post":"World"') + ->assertSee('"during_request_post":"World"') + ->assertSee('"during_request_get":"query-value"') + ->assertSee('"during_cookie":"cookie-value"') + ->assertSee('"during_server_probe":null') + ->assertSee('"during_query_string":"source=query-value"') + ->assertSee('"request_cookie":"cookie-value"'); + + expect($_GET['__restore_probe_get'] ?? null)->toBe('before-get'); + expect($_POST['__restore_probe_post'] ?? null)->toBe('before-post'); + expect($_REQUEST['__restore_probe_request'] ?? null)->toBe('before-request'); + expect($_SERVER['__RESTORE_PROBE_SERVER'] ?? null)->toBe('before-server'); + expect($_COOKIE['__restore_probe_cookie'] ?? null)->toBe('before-cookie'); + } finally { + $_GET = $originalGet; + $_POST = $originalPost; + $_REQUEST = $originalRequest; + $_SERVER = $originalServer; + $_COOKIE = $originalCookie; + } +}); diff --git a/tests/Unit/Http/ExtendedFormParserTest.php b/tests/Unit/Http/ExtendedFormParserTest.php new file mode 100644 index 00000000..ae61bb65 --- /dev/null +++ b/tests/Unit/Http/ExtendedFormParserTest.php @@ -0,0 +1,1034 @@ + " + + + +
+ + + + +
+ + + "); + Route::post('/form', static fn (Request $request): string => " + + + +

Hello {$request->post('name')}

+ + + "); + + $page = visit('/'); + $page->assertSee('Your name'); + + $page->fill('Your name', 'World'); + $page->click('Send'); + + $page->assertSee('Hello World'); +}); + +it('matches content-type in server variables for URL-encoded form submissions', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + + Route::post('/content-type', static function (Request $request): string { + $matches = $request->server('CONTENT_TYPE') === $request->header('content-type'); + + return $matches ? 'true' : 'false'; + }); + + $page = visit('/'); + + $page->fill('Your name', 'World'); + $page->click('Send'); + + $page->assertSee('true'); +}); + +it('overrides conflicting CONTENT_TYPE from server variables with request header', function (): void { + withServerVariables(['CONTENT_TYPE' => 'text/plain']); + + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + + Route::post('/content-type', static function (Request $request): string { + $matches = $request->server('CONTENT_TYPE') === $request->header('content-type'); + + return $matches ? 'true' : 'false'; + }); + + $page = visit('/'); + + $page->fill('Your name', 'World'); + $page->click('Send'); + + $page->assertSee('true'); +}); + +it('matches content-length in server variables for URL-encoded form submissions', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + + Route::post('/content-length', static function (Request $request): string { + $serverLength = (string) ($request->server('CONTENT_LENGTH') ?? ''); + $headerLength = (string) ($request->header('content-length') ?? ''); + $matches = $serverLength !== '' && $serverLength === $headerLength; + + return $matches ? 'true' : 'false'; + }); + + Playwright::usingTimeout(15_000, function (): void { + $page = visit('/'); + + $page->fill('Your name', 'World'); + $page->click('Send'); + + $page->assertSee('true'); + }); +}); + +it('overrides conflicting CONTENT_LENGTH from server variables with request header', function (): void { + withServerVariables(['CONTENT_LENGTH' => '1']); + + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + + Route::post('/content-length', static function (Request $request): string { + $serverLength = (string) ($request->server('CONTENT_LENGTH') ?? ''); + $headerLength = (string) ($request->header('content-length') ?? ''); + $matches = $serverLength !== '' && $serverLength === $headerLength; + + return $matches ? 'true' : 'false'; + }); + + Playwright::usingTimeout(15_000, function (): void { + $page = visit('/'); + + $page->fill('Your name', 'World'); + $page->click('Send'); + + $page->assertSee('true'); + }); +}); + +it('overrides conflicting CONTENT_MD5 from server variables with request header', function (): void { + withServerVariables(['CONTENT_MD5' => 'stale-md5']); + + Route::get('/content-md5', static fn (): string => " + + + + +

+
+            
+        
+        
+    ");
+
+    Route::post('/content-md5', static function (Request $request): string {
+        $serverMd5 = (string) ($request->server('CONTENT_MD5') ?? '');
+        $headerMd5 = (string) ($request->header('content-md5') ?? '');
+
+        $payload = [
+            'server_md5' => $serverMd5,
+            'header_md5' => $headerMd5,
+            'md5_matches_header' => $serverMd5 !== '' && $serverMd5 === $headerMd5,
+        ];
+
+        $json = json_encode($payload, JSON_UNESCAPED_UNICODE);
+        assert($json !== false);
+
+        return '
'.$json.'
'; + }); + + $page = visit('/content-md5'); + + $page->click('Send'); + + $page->assertSee('"server_md5":"abc123"') + ->assertSee('"header_md5":"abc123"') + ->assertSee('"md5_matches_header":true'); +}); + +it('parse a multipart body with files', function (): void { + Route::get('favicon.ico', static fn (): string => ''); + Route::get('/', static fn (): string => " + + + +
+ + + + + + + + + + + + + +
+ + + "); + Route::post('/form', static fn (Request $request): string => " + + + +

Hello {$request->post('name')}

+

Text file: {$request->file('file1')?->getClientOriginalName()}

+

Binary file: {$request->file('file2')?->getClientOriginalName()}

+

Empty file: {$request->file('file3')?->getClientOriginalName()}

+ + + "); + + Playwright::usingTimeout(15_000, function (): void { + $page = visit('/'); + $page->assertSee('Your name'); + + $page->fill('Your name', 'World'); + $page->attach('Your text file', fixture('lorem-ipsum.txt')); + $page->attach('Your binary file', fixture('example.pdf')); + $page->submit(); + + $page->assertSee('Hello World'); + $page->assertSee('Text file: lorem-ipsum.txt'); + $page->assertSee('Binary file: example.pdf'); + $page->assertSee('Empty file: '); + }); +}); + +it('matches content-type in server variables when uploading files', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + + Route::post('/content-type', static function (Request $request): string { + $matches = $request->server('CONTENT_TYPE') === $request->header('content-type'); + + return $matches ? 'true' : 'false'; + }); + + Playwright::usingTimeout(15_000, function (): void { + visit('/') + ->attach('A file', fixture('lorem-ipsum.txt')) + ->click('Send') + ->assertSee('true'); + }); +}); + +it('matches content-length in server variables when uploading files', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + + Route::post('/content-length', static function (Request $request): string { + $serverLength = (string) ($request->server('CONTENT_LENGTH') ?? ''); + $headerLength = (string) ($request->header('content-length') ?? ''); + $matches = $serverLength !== '' && $serverLength === $headerLength; + + return $matches ? 'true' : 'false'; + }); + + Playwright::usingTimeout(15_000, function (): void { + visit('/') + ->attach('A file', fixture('lorem-ipsum.txt')) + ->click('Send') + ->assertSee('true'); + }); +}); + +it('preserves custom server variables while synchronizing upload content headers', function (): void { + withServerVariables([ + 'X_CUSTOM_UPLOAD_FLAG' => 'enabled', + 'CONTENT_TYPE' => 'text/plain', + 'CONTENT_LENGTH' => '1', + ]); + + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + + Route::post('/server-vars', static function (Request $request): string { + $custom = (string) ($request->server('X_CUSTOM_UPLOAD_FLAG') ?? ''); + $serverType = (string) ($request->server('CONTENT_TYPE') ?? ''); + $headerType = (string) ($request->header('content-type') ?? ''); + $serverLength = (string) ($request->server('CONTENT_LENGTH') ?? ''); + $headerLength = (string) ($request->header('content-length') ?? ''); + + $payload = [ + 'custom_preserved' => $custom === 'enabled', + 'type_matches_header' => $serverType === $headerType, + 'length_matches_header' => $serverLength === $headerLength, + ]; + + $json = json_encode($payload, JSON_UNESCAPED_UNICODE); + assert($json !== false); + + return '
'.$json.'
'; + }); + + Playwright::usingTimeout(15_000, function (): void { + visit('/') + ->attach('A file', fixture('lorem-ipsum.txt')) + ->click('Send') + ->assertSee('"custom_preserved":true') + ->assertSee('"type_matches_header":true') + ->assertSee('"length_matches_header":true'); + }); +}); + +it('hydrates request superglobals during kernel handling', function (): void { + Route::get('/superglobals', static fn () => response()->make(" + + + +
+ + + + +
+ + + ")->cookie('super_cookie', 'cookie-value')); + + Route::post('/superglobals', static fn (Request $request) => response()->json([ + 'post_name' => $_POST['name'] ?? null, + 'request_name' => $_REQUEST['name'] ?? null, + 'get_source' => $_GET['source'] ?? null, + 'cookie_super' => $_COOKIE['super_cookie'] ?? null, + 'query_string' => $_SERVER['QUERY_STRING'] ?? null, + 'request_query_source' => $request->query('source'), + 'request_cookie_super' => $request->cookie('super_cookie'), + 'type_has_form' => str_contains((string) ($_SERVER['CONTENT_TYPE'] ?? ''), 'application/x-www-form-urlencoded'), + 'length_matches' => (string) ($_SERVER['CONTENT_LENGTH'] ?? '') === (string) ($request->header('content-length') ?? ''), + ])); + + $page = visit('/superglobals'); + + $page->fill('Your name', 'World'); + $page->click('Send'); + + $page->assertSee('"post_name":"World"') + ->assertSee('"request_name":"World"') + ->assertSee('"get_source":"query-value"') + ->assertSee('"cookie_super":"cookie-value"') + ->assertSee('"query_string":"source=query-value"') + ->assertSee('"request_query_source":"query-value"') + ->assertSee('"request_cookie_super":"cookie-value"') + ->assertSee('"type_has_form":true') + ->assertSee('"length_matches":true'); +}); + +it('keeps content server variables empty on GET requests', function (): void { + Route::get('/server-content-vars', static function (Request $request): string { + $payload = [ + 'server_content_type' => $request->server('CONTENT_TYPE'), + 'server_content_length' => $request->server('CONTENT_LENGTH'), + 'header_content_type' => $request->header('content-type'), + 'header_content_length' => $request->header('content-length'), + ]; + + $json = json_encode($payload, JSON_UNESCAPED_UNICODE); + assert($json !== false); + + return '
'.$json.'
'; + }); + + visit('/server-content-vars') + ->assertSee('"server_content_type":null') + ->assertSee('"server_content_length":null') + ->assertSee('"header_content_type":null') + ->assertSee('"header_content_length":null'); +}); + +it('preserves multipart boundary and numeric content-length in server variables', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + + Route::post('/content-metadata', static function (Request $request): string { + $serverType = (string) ($request->server('CONTENT_TYPE') ?? ''); + $headerType = (string) ($request->header('content-type') ?? ''); + $serverLength = (string) ($request->server('CONTENT_LENGTH') ?? ''); + $headerLength = (string) ($request->header('content-length') ?? ''); + + $payload = [ + 'type_matches' => $serverType === $headerType, + 'length_matches' => $serverLength === $headerLength, + 'has_boundary' => str_contains($serverType, 'boundary='), + 'length_is_numeric' => $serverLength !== '' && ctype_digit($serverLength), + ]; + + $json = json_encode($payload, JSON_UNESCAPED_UNICODE); + assert($json !== false); + + return '
'.$json.'
'; + }); + + Playwright::usingTimeout(15_000, function (): void { + visit('/') + ->attach('A file', fixture('lorem-ipsum.txt')) + ->click('Send') + ->assertSee('"type_matches":true') + ->assertSee('"length_matches":true') + ->assertSee('"has_boundary":true') + ->assertSee('"length_is_numeric":true'); + }); +}); + +it('applies MAX_FILE_SIZE multipart validation error', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + + + +
+ + + "); + + Route::post('/form', static function (Request $request): string { + $document = $request->file('document'); + + return ' + + + +

Has file: '.($document !== null ? 'yes' : 'no').'

+

Error: '.($document?->getError() ?? 'none').'

+ + + '; + }); + + Playwright::usingTimeout(15_000, function (): void { + $page = visit('/'); + + $page->attach('Document', fixture('lorem-ipsum.txt')); + $page->submit(); + + $page->assertSee('Has file: yes') + ->assertSee('Error: '.UPLOAD_ERR_FORM_SIZE); + }); +}); + +it('applies UPLOAD_ERR_NO_FILE when no file is selected', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + + Route::post('/form', static function (Request $request): string { + $file = $request->files->get('document'); + + $hasNoFileError = $file === null + || ( + $file instanceof Illuminate\Http\UploadedFile + && $file->getError() === UPLOAD_ERR_NO_FILE + ); + + return ' + + + +

No file error: '.($hasNoFileError ? 'yes' : 'no').'

+ + + '; + }); + + Playwright::usingTimeout(15_000, function (): void { + $page = visit('/'); + $page->submit(); + + $page->assertSee('No file error: yes'); + }); +}); + +it('applies UPLOAD_ERR_INI_SIZE for oversized multipart upload', function (): void { + $http = Pest\Browser\ServerManager::instance()->http(); + assert($http instanceof Pest\Browser\Drivers\LaravelHttpServer); + + $http->setExtendedFormParser(new Pest\Browser\Http\ExtendedFormParser( + maxInputVars: 1000, + uploadMaxFilesize: 1, + maxFileUploads: 20, + )); + + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + + Route::post('/form', static function (Request $request): string { + $document = $request->file('document'); + + return ' + + + +

Error: '.($document?->getError() ?? 'none').'

+ + + '; + }); + + $oversizedFile = tempnam(sys_get_temp_dir(), 'multipart-oversized-'); + assert($oversizedFile !== false); + + try { + $written = file_put_contents($oversizedFile, 'AB'); + assert($written !== false); + + Playwright::usingTimeout(15_000, function () use ($oversizedFile): void { + $page = visit('/'); + $page->attach('Document', $oversizedFile); + $page->submit(); + + $page->assertSee('Error: '.UPLOAD_ERR_INI_SIZE); + }); + } finally { + $http->setExtendedFormParser(Pest\Browser\Http\ExtendedFormParser::fromIni()); + unlink($oversizedFile); + } +}); + +it('enforces upload limits using byte size for multibyte file contents', function (): void { + $http = Pest\Browser\ServerManager::instance()->http(); + assert($http instanceof Pest\Browser\Drivers\LaravelHttpServer); + + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + + Route::post('/form', static function (Request $request): string { + $document = $request->file('document'); + + return ' + + + +

Error: '.($document?->getError() ?? 'none').'

+

Size: '.($document?->getSize() ?? 'none').'

+ + + '; + }); + + $multibyteContents = str_repeat('ยข', 3); + $charLength = mb_strlen($multibyteContents); + $byteLength = mb_strlen($multibyteContents, '8bit'); + + expect($charLength)->toBe(3); + expect($byteLength)->toBe(6); + + $multibyteFile = tempnam(sys_get_temp_dir(), 'multipart-multibyte-'); + assert($multibyteFile !== false); + + try { + $written = file_put_contents($multibyteFile, $multibyteContents); + assert($written !== false); + + $http->setExtendedFormParser(new Pest\Browser\Http\ExtendedFormParser( + maxInputVars: 1000, + uploadMaxFilesize: $byteLength - 1, + maxFileUploads: 20, + )); + + Playwright::usingTimeout(15_000, function () use ($multibyteFile): void { + $page = visit('/'); + $page->attach('Document', $multibyteFile); + $page->submit(); + + $page->assertSee('Error: '.UPLOAD_ERR_INI_SIZE); + }); + + $http->setExtendedFormParser(new Pest\Browser\Http\ExtendedFormParser( + maxInputVars: 1000, + uploadMaxFilesize: $byteLength, + maxFileUploads: 20, + )); + + Playwright::usingTimeout(15_000, function () use ($multibyteFile, $byteLength): void { + $page = visit('/'); + $page->attach('Document', $multibyteFile); + $page->submit(); + + $page->assertSee('Error: 0') + ->assertSee('Size: '.$byteLength); + }); + } finally { + $http->setExtendedFormParser(Pest\Browser\Http\ExtendedFormParser::fromIni()); + unlink($multibyteFile); + } +}); + +it('validates multipart pdf upload metadata', function (): void { + Route::get('favicon.ico', static fn (): string => ''); + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + + $expectedPdfSize = filesize(fixture('example.pdf')); + assert($expectedPdfSize !== false); + + Route::post('/form', static function (Request $request) use ($expectedPdfSize): string { + $pdf = $request->file('pdf_file'); + $name = $pdf?->getClientOriginalName() ?? ''; + $extension = $pdf?->getClientOriginalExtension() ?? ''; + $valid = $pdf?->isValid() ? 'yes' : 'no'; + $size = (string) ($pdf?->getSize() ?? ''); + + return " + + + +

Name: $name

+

Extension: $extension

+

Valid: $valid

+

Size: $size

+

Expected size: $expectedPdfSize

+ + + "; + }); + + Playwright::usingTimeout(15000, function () use ($expectedPdfSize): void { + $page = visit('/'); + + $page->attach('PDF file', fixture('example.pdf')); + $page->submit(); + + $page->assertSee('Name: example.pdf') + ->assertSee('Extension: pdf') + ->assertSee('Valid: yes') + ->assertSee('Expected size: '.$expectedPdfSize) + ->assertSee('Size: '.$expectedPdfSize); + }); +}); + +it('parse a multipart body with nested fields', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + + + + + + + + + + + + + +
+ + + "); + Route::post('/form', static function (Request $request): string { + $childNames = implode(', ', (array) $request->input('children')); + + return " + + + +

Hello {$request->input('person.first_name')} {$request->input('person.last_name')}

+

and $childNames

+ + + "; + }); + + $page = visit('/'); + + $page->fill('Your first name', 'Jane'); + $page->fill('Your last name', 'Doe'); + $page->fill('Child 2', 'Johnathan'); + $page->fill('Child 3', 'Jamie'); + $page->fill('Child 1', 'John'); + $page->submit(); + + $page->assertSee('Hello Jane Doe') + ->assertSee('and John, Johnathan, Jamie'); +}); + +it('parses linear nested indexed multipart fields without merging values', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + "); + + Route::post('/form', static function (Request $request): string { + $payload = json_encode($request->all(), JSON_UNESCAPED_UNICODE); + assert($payload !== false); + + return ' + + + +
'.$payload.'
+ + + '; + }); + + Playwright::usingTimeout(15_000, function (): void { + $page = visit('/'); + + $page->fill('Extra', 'Test'); + $page->fill('Data 0 Field 1', '1'); + $page->fill('Data 0 Field 2', '1'); + $page->fill('Data 1 Field 1', '2'); + $page->fill('Data 1 Field 2', '0'); + $page->fill('Matrix 1', 'A'); + $page->fill('Matrix 2', 'B'); + $page->submit(); + + $expectedPayload = json_encode([ + 'extra' => 'Test', + 'data' => [ + [ + 'field1' => '1', + 'field2' => '1', + ], + [ + 'field1' => '2', + 'field2' => '0', + ], + ], + 'matrix' => [ + ['A'], + ['B'], + ], + ], JSON_UNESCAPED_UNICODE); + assert($expectedPayload !== false); + + $page->assertSee($expectedPayload) + ->assertSee('"extra":"Test"') + ->assertSee('"data":[{"field1":"1","field2":"1"},{"field1":"2","field2":"0"}]') + ->assertSee('"matrix":[["A"],["B"]]'); + }); +}); + +it('preserves sparse numeric indexes in multipart nested fields', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + + + + +
+ + + "); + + Route::post('/form', static function (Request $request): string { + $payload = json_encode($request->all(), JSON_UNESCAPED_UNICODE); + assert($payload !== false); + + return '
'.$payload.'
'; + }); + + Playwright::usingTimeout(15_000, function (): void { + $page = visit('/'); + + $page->fill('Data 2 Field', 'A'); + $page->fill('Data 5 Field', 'B'); + $page->submit(); + + $page->assertSee('"data":{"2":{"field":"A"},"5":{"field":"B"}}'); + }); +}); + +it('overwrites duplicate explicit multipart keys with last value', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + + + + +
+ + + "); + + Route::post('/form', static fn (Request $request): string => '

Field2: '.$request->input('data.0.field2').'

'); + + Playwright::usingTimeout(15_000, function (): void { + $page = visit('/'); + + $page->fill('Field2 first', 'first'); + $page->fill('Field2 second', 'second'); + $page->submit(); + + $page->assertSee('Field2: second'); + }); +}); + +it('handles mixed multipart payload with files and nested arrays', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + + + + + + + + + + + + + +
+ + + "); + + Route::post('/form', static function (Request $request): string { + $payload = [ + 'fields' => $request->all(), + 'file' => [ + 'name' => $request->file('document')?->getClientOriginalName(), + 'error' => $request->file('document')?->getError(), + ], + ]; + + $json = json_encode($payload, JSON_UNESCAPED_UNICODE); + assert($json !== false); + + return '
'.$json.'
'; + }); + + Playwright::usingTimeout(15_000, function (): void { + $page = visit('/'); + + $page->fill('Extra', 'Mix'); + $page->fill('Data 0 Field 1', '10'); + $page->fill('Flag 1', 'X'); + $page->fill('Flag 2', 'Y'); + $page->attach('Document', fixture('lorem-ipsum.txt')); + $page->submit(); + + $page->assertSee('"extra":"Mix"') + ->assertSee('"data":[{"field1":"10"}]') + ->assertSee('"flags":[["X"],["Y"]]') + ->assertSee('"name":"lorem-ipsum.txt"') + ->assertSee('"error":0'); + }); +});