From e54b94ee665944c5e8135e37315c3242811b1f41 Mon Sep 17 00:00:00 2001 From: Michael Mantos Date: Mon, 16 Feb 2026 02:37:12 +0000 Subject: [PATCH 1/6] fix: Add multipart form-data support for Laravel HTTP server --- composer.json | 1 + src/Drivers/LaravelHttpServer.php | 197 +++++++++++++++++- tests/Fixtures/example.pdf | Bin 0 -> 46753 bytes tests/Fixtures/lorem-ipsum.txt | 9 + .../Drivers/Laravel/LaravelHttpServerTest.php | 136 ++++++++++++ 5 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 tests/Fixtures/example.pdf create mode 100644 tests/Fixtures/lorem-ipsum.txt 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..eb21fec4 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -4,9 +4,13 @@ namespace Pest\Browser\Drivers; +use const UPLOAD_ERR_OK; + use Amp\ByteStream\ReadableResourceStream; use Amp\Http\Cookie\RequestCookie; use Amp\Http\Server\DefaultErrorHandler; +use Amp\Http\Server\FormParser\BufferedFile; +use Amp\Http\Server\FormParser\Form; use Amp\Http\Server\HttpServer as AmpHttpServer; use Amp\Http\Server\HttpServerStatus; use Amp\Http\Server\Request as AmpRequest; @@ -25,6 +29,7 @@ use Pest\Browser\GlobalState; use Pest\Browser\Playwright\Playwright; use Psr\Log\NullLogger; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\Mime\MimeTypes; use Throwable; @@ -239,11 +244,21 @@ 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 */ @@ -254,7 +269,7 @@ private function handleRequest(AmpRequest $request): Response $method, $parameters, $cookies, - [], // @TODO files... + $files, $serverVariables, $rawBody ); @@ -362,4 +377,178 @@ 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 + { + $form = Form::fromRequest($request); + + $values = $form->getValues(); + $files = $form->getFiles(); + + return [ + $this->normalizeMultipartParameters($values), + $this->normalizeMultipartFiles($files), + ]; + } + + /** + * Normalize multipart field values to a Symfony request-compatible array. + * + * @param array> $fields + * @return array + */ + private function normalizeMultipartParameters(array $fields): array + { + $normalized = []; + + foreach ($fields as $field => $values) { + foreach ($values as $value) { + $this->setFieldValue($normalized, $field, $value); + } + } + + return $normalized; + } + + /** + * Normalize multipart files to a Symfony request-compatible files array. + * + * @param array> $files + * @return array + */ + private function normalizeMultipartFiles(array $files): array + { + $normalized = []; + + foreach ($files as $field => $fileEntries) { + foreach ($fileEntries as $fileEntry) { + $this->setFieldValue($normalized, $field, $this->createUploadedFile($fileEntry)); + } + } + + return $normalized; + } + + /** + * @param array $target + */ + private function setFieldValue(array &$target, string $field, string|UploadedFile $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, string|UploadedFile $value): void + { + $segment = array_shift($segments); + + if ($segment === null) { + return; + } + + if ($segments === []) { + if ($segment === '') { + $target[] = $value; + + return; + } + + if (! array_key_exists($segment, $target)) { + $target[$segment] = $value; + + return; + } + + if (! is_array($target[$segment])) { + $target[$segment] = [$target[$segment]]; + } + + $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); + } + + private function createUploadedFile(object $fileEntry): UploadedFile + { + $tempPath = tempnam(sys_get_temp_dir(), 'pest-browser-upload-'); + assert($tempPath !== false, 'Failed to create temporary upload file.'); + + $contents = method_exists($fileEntry, 'getContents') ? $fileEntry->getContents() : ''; + $contents = is_string($contents) ? $contents : ''; + + file_put_contents($tempPath, $contents); + + $clientFilename = method_exists($fileEntry, 'getName') ? $fileEntry->getName() : 'upload'; + $clientFilename = is_string($clientFilename) && $clientFilename !== '' ? $clientFilename : 'upload'; + + $mimeType = method_exists($fileEntry, 'getMimeType') ? $fileEntry->getMimeType() : 'application/octet-stream'; + $mimeType = is_string($mimeType) && $mimeType !== '' ? $mimeType : 'application/octet-stream'; + + return new UploadedFile( + $tempPath, + $clientFilename, + $mimeType, + UPLOAD_ERR_OK, + true, + ); + } } diff --git a/tests/Fixtures/example.pdf b/tests/Fixtures/example.pdf new file mode 100644 index 0000000000000000000000000000000000000000..afa36062bc605220a3d64e10f06abf0d801df772 GIT binary patch literal 46753 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 GhW`P&W6Jsf literal 0 HcmV?d00001 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..1e093977 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,138 @@ visit('/server-variables') ->assertSee('"test-server-key":"test value"'); }); + +it('parse a URL-encoded body', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + 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('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()}

+ + + "); + + $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('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'); +}); From 90a5998c9c42f65ebbc11c5ce2c83b9f032291e9 Mon Sep 17 00:00:00 2001 From: Michael Mantos Date: Mon, 16 Feb 2026 06:04:26 +0000 Subject: [PATCH 2/6] fix: Use Illuminate\Http\UploadedFile to avoid conversion and ignore test property --- src/Drivers/LaravelHttpServer.php | 4 +- .../Drivers/Laravel/LaravelHttpServerTest.php | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index eb21fec4..95b757b6 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -4,8 +4,6 @@ namespace Pest\Browser\Drivers; -use const UPLOAD_ERR_OK; - use Amp\ByteStream\ReadableResourceStream; use Amp\Http\Cookie\RequestCookie; use Amp\Http\Server\DefaultErrorHandler; @@ -21,6 +19,7 @@ use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Foundation\Testing\Concerns\WithoutExceptionHandlingHandler; use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Uri; use Pest\Browser\Contracts\HttpServer; @@ -29,7 +28,6 @@ use Pest\Browser\GlobalState; use Pest\Browser\Playwright\Playwright; use Psr\Log\NullLogger; -use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\Mime\MimeTypes; use Throwable; diff --git a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php index 1e093977..2e02b19c 100644 --- a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php +++ b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php @@ -120,6 +120,58 @@ $page->assertSee('Empty file: '); }); +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

+ + + "; + }); + + $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 => " From 6e72b2e92a5fad2d9ecf5dde40be1a411f1e1f62 Mon Sep 17 00:00:00 2001 From: Michael Mantos Date: Thu, 26 Feb 2026 00:35:41 +0000 Subject: [PATCH 3/6] fix: improve multipart file parsing and upload error handling in LaravelHttpServer --- src/Drivers/LaravelHttpServer.php | 185 +---------- src/Http/ExtendedFormParser.php | 287 ++++++++++++++++++ tests/ArchTest.php | 1 + .../Drivers/Laravel/LaravelHttpServerTest.php | 262 ++++++++++++++-- 4 files changed, 548 insertions(+), 187 deletions(-) create mode 100644 src/Http/ExtendedFormParser.php diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index 95b757b6..0fdce8b8 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -7,8 +7,6 @@ use Amp\ByteStream\ReadableResourceStream; use Amp\Http\Cookie\RequestCookie; use Amp\Http\Server\DefaultErrorHandler; -use Amp\Http\Server\FormParser\BufferedFile; -use Amp\Http\Server\FormParser\Form; use Amp\Http\Server\HttpServer as AmpHttpServer; use Amp\Http\Server\HttpServerStatus; use Amp\Http\Server\Request as AmpRequest; @@ -19,13 +17,13 @@ use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Foundation\Testing\Concerns\WithoutExceptionHandlingHandler; use Illuminate\Http\Request; -use Illuminate\Http\UploadedFile; use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Uri; use Pest\Browser\Contracts\HttpServer; 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; @@ -53,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. */ @@ -60,7 +63,7 @@ public function __construct( public readonly string $host, public readonly int $port, ) { - // + $this->extendedFormParser = ExtendedFormParser::fromIni(); } /** @@ -72,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. */ @@ -383,170 +394,6 @@ private function rewriteAssetUrl(string $content): string */ private function parseMultipartFormData(AmpRequest $request): array { - $form = Form::fromRequest($request); - - $values = $form->getValues(); - $files = $form->getFiles(); - - return [ - $this->normalizeMultipartParameters($values), - $this->normalizeMultipartFiles($files), - ]; - } - - /** - * Normalize multipart field values to a Symfony request-compatible array. - * - * @param array> $fields - * @return array - */ - private function normalizeMultipartParameters(array $fields): array - { - $normalized = []; - - foreach ($fields as $field => $values) { - foreach ($values as $value) { - $this->setFieldValue($normalized, $field, $value); - } - } - - return $normalized; - } - - /** - * Normalize multipart files to a Symfony request-compatible files array. - * - * @param array> $files - * @return array - */ - private function normalizeMultipartFiles(array $files): array - { - $normalized = []; - - foreach ($files as $field => $fileEntries) { - foreach ($fileEntries as $fileEntry) { - $this->setFieldValue($normalized, $field, $this->createUploadedFile($fileEntry)); - } - } - - return $normalized; - } - - /** - * @param array $target - */ - private function setFieldValue(array &$target, string $field, string|UploadedFile $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, string|UploadedFile $value): void - { - $segment = array_shift($segments); - - if ($segment === null) { - return; - } - - if ($segments === []) { - if ($segment === '') { - $target[] = $value; - - return; - } - - if (! array_key_exists($segment, $target)) { - $target[$segment] = $value; - - return; - } - - if (! is_array($target[$segment])) { - $target[$segment] = [$target[$segment]]; - } - - $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); - } - - private function createUploadedFile(object $fileEntry): UploadedFile - { - $tempPath = tempnam(sys_get_temp_dir(), 'pest-browser-upload-'); - assert($tempPath !== false, 'Failed to create temporary upload file.'); - - $contents = method_exists($fileEntry, 'getContents') ? $fileEntry->getContents() : ''; - $contents = is_string($contents) ? $contents : ''; - - file_put_contents($tempPath, $contents); - - $clientFilename = method_exists($fileEntry, 'getName') ? $fileEntry->getName() : 'upload'; - $clientFilename = is_string($clientFilename) && $clientFilename !== '' ? $clientFilename : 'upload'; - - $mimeType = method_exists($fileEntry, 'getMimeType') ? $fileEntry->getMimeType() : 'application/octet-stream'; - $mimeType = is_string($mimeType) && $mimeType !== '' ? $mimeType : 'application/octet-stream'; - - return new UploadedFile( - $tempPath, - $clientFilename, - $mimeType, - UPLOAD_ERR_OK, - true, - ); + return $this->extendedFormParser->parseMultipart($request); } } diff --git a/src/Http/ExtendedFormParser.php b/src/Http/ExtendedFormParser.php new file mode 100644 index 00000000..857fbfb2 --- /dev/null +++ b/src/Http/ExtendedFormParser.php @@ -0,0 +1,287 @@ +, 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; + } + + if (! array_key_exists($segment, $target)) { + $target[$segment] = $value; + + return; + } + + if (! is_array($target[$segment])) { + $target[$segment] = [$target[$segment]]; + } + + $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/Unit/Drivers/Laravel/LaravelHttpServerTest.php b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php index 2e02b19c..ac246a1c 100644 --- a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php +++ b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; +use Pest\Browser\Playwright\Playwright; use function Pest\Laravel\withServerVariables; use function Pest\Laravel\withUnencryptedCookie; @@ -106,18 +107,241 @@ "); - $page = visit('/'); - $page->assertSee('Your name'); + 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->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: '); + $page->assertSee('Hello World'); + $page->assertSee('Text file: lorem-ipsum.txt'); + $page->assertSee('Binary file: example.pdf'); + $page->assertSee('Empty file: '); + }); +}); + +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 { @@ -160,16 +384,18 @@ "; }); - $page = visit('/'); + Playwright::usingTimeout(15000, function () use ($expectedPdfSize): void { + $page = visit('/'); - $page->attach('PDF file', fixture('example.pdf')); - $page->submit(); + $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); + $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 { From f0400c02f1642889e9fbf3133d6bafd4b4526491 Mon Sep 17 00:00:00 2001 From: Michael Mantos Date: Thu, 26 Feb 2026 21:47:06 +0000 Subject: [PATCH 4/6] feat(http): extract multipart parser tests and harden form parsing --- src/Http/ExtendedFormParser.php | 12 +- .../Drivers/Laravel/LaravelHttpServerTest.php | 413 ------------ tests/Unit/Http/ExtendedFormParserTest.php | 631 ++++++++++++++++++ 3 files changed, 632 insertions(+), 424 deletions(-) create mode 100644 tests/Unit/Http/ExtendedFormParserTest.php diff --git a/src/Http/ExtendedFormParser.php b/src/Http/ExtendedFormParser.php index 857fbfb2..df662e78 100644 --- a/src/Http/ExtendedFormParser.php +++ b/src/Http/ExtendedFormParser.php @@ -250,17 +250,7 @@ private function setNestedFieldValue(array &$target, array $segments, mixed $val return; } - if (! array_key_exists($segment, $target)) { - $target[$segment] = $value; - - return; - } - - if (! is_array($target[$segment])) { - $target[$segment] = [$target[$segment]]; - } - - $target[$segment][] = $value; + $target[$segment] = $value; return; } diff --git a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php index ac246a1c..08e0c879 100644 --- a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php +++ b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php @@ -4,7 +4,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; -use Pest\Browser\Playwright\Playwright; use function Pest\Laravel\withServerVariables; use function Pest\Laravel\withUnencryptedCookie; @@ -38,415 +37,3 @@ visit('/server-variables') ->assertSee('"test-server-key":"test value"'); }); - -it('parse a URL-encoded body', function (): void { - Route::get('/', static fn (): string => " - - - -
- - - - -
- - - "); - 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('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('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'); -}); diff --git a/tests/Unit/Http/ExtendedFormParserTest.php b/tests/Unit/Http/ExtendedFormParserTest.php new file mode 100644 index 00000000..cac3b244 --- /dev/null +++ b/tests/Unit/Http/ExtendedFormParserTest.php @@ -0,0 +1,631 @@ + " + + + +
+ + + + +
+ + + "); + 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('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('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'); + }); +}); From 8e3660fa2f51c84fb9680b81206809718d88d448 Mon Sep 17 00:00:00 2001 From: Michael Mantos Date: Thu, 5 Mar 2026 10:55:34 +0000 Subject: [PATCH 5/6] fix(http): sync CONTENT_TYPE and CONTENT_LENGTH server vars with request headers --- src/Drivers/LaravelHttpServer.php | 9 + tests/Unit/Http/ExtendedFormParserTest.php | 307 +++++++++++++++++++++ 2 files changed, 316 insertions(+) diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index 0fdce8b8..cbca8e89 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -273,6 +273,15 @@ private function handleRequest(AmpRequest $request): Response /** @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; + } + $symfonyRequest = Request::create( $absoluteUrl, $method, diff --git a/tests/Unit/Http/ExtendedFormParserTest.php b/tests/Unit/Http/ExtendedFormParserTest.php index cac3b244..54d118e9 100644 --- a/tests/Unit/Http/ExtendedFormParserTest.php +++ b/tests/Unit/Http/ExtendedFormParserTest.php @@ -6,6 +6,8 @@ use Illuminate\Support\Facades\Route; use Pest\Browser\Playwright\Playwright; +use function Pest\Laravel\withServerVariables; + it('parse a URL-encoded body', function (): void { Route::get('/', static fn (): string => " @@ -38,6 +40,134 @@ $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('parse a multipart body with files', function (): void { Route::get('favicon.ico', static fn (): string => ''); Route::get('/', static fn (): string => " @@ -90,6 +220,183 @@ }); }); +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('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 => " From 83a2a9d9009b9233a986ea8e0c8fce46cf756893 Mon Sep 17 00:00:00 2001 From: Michael Mantos Date: Thu, 5 Mar 2026 16:41:43 +0000 Subject: [PATCH 6/6] feat(http): sync CONTENT_MD5 and verify superglobal hydration/restore --- src/Drivers/LaravelHttpServer.php | 35 +++++++ .../Drivers/Laravel/LaravelHttpServerTest.php | 67 +++++++++++++ tests/Unit/Http/ExtendedFormParserTest.php | 96 +++++++++++++++++++ 3 files changed, 198 insertions(+) diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index cbca8e89..7ae1ec80 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -282,6 +282,11 @@ private function handleRequest(AmpRequest $request): Response $serverVariables['CONTENT_LENGTH'] = $contentLength; } + $contentMd5 = $request->getHeader('content-md5'); + if ($contentMd5 !== null && $contentMd5 !== '') { + $serverVariables['CONTENT_MD5'] = $contentMd5; + } + $symfonyRequest = Request::create( $absoluteUrl, $method, @@ -304,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 { @@ -316,6 +324,7 @@ private function handleRequest(AmpRequest $request): Response throw $e; } finally { config(['app.debug' => $debug]); + $this->restoreRequestSuperglobals($superglobalState); } $kernel->terminate($laravelRequest, $response); @@ -405,4 +414,30 @@ 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/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php index 08e0c879..524db35b 100644 --- a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php +++ b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php @@ -37,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 index 54d118e9..ae61bb65 100644 --- a/tests/Unit/Http/ExtendedFormParserTest.php +++ b/tests/Unit/Http/ExtendedFormParserTest.php @@ -168,6 +168,59 @@ }); }); +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 => " @@ -330,6 +383,49 @@ }); }); +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 = [