From b9bef63010dd7461ceaa7e1cdb81ebf11401efc3 Mon Sep 17 00:00:00 2001 From: Henry Avila Date: Tue, 26 May 2026 09:56:07 -0300 Subject: [PATCH] feat(driver): parse multipart/form-data and forward uploaded files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LaravelHttpServer::handleRequest()` was passing `[]` to the `$files` argument of `Symfony\Component\HttpFoundation\Request::create()` with a `// @TODO files...` placeholder. Any test that drove a real upload through Playwright (`->attach()`) would have the file written to the `` and the `change` event dispatched correctly, but the Laravel kernel would never see the file: the existing `Tests/Browser/Webpage/ AttachTest.php` only asserts on `input.files[0].name` client-side, which masked the gap. This commit implements `parseMultipartBody()` invoked when the request content type starts with `multipart/form-data`. It is byte-safe (uses plain string functions, never `mb_*`) so binary uploads — .xlsx, .zip, images, PDFs — survive the parse without UTF-8 corruption. Each file part is written to a temp file in `sys_get_temp_dir()` and wrapped in a Symfony `UploadedFile` constructed with `$test: true` so it bypasses `is_uploaded_file()` (which only returns true for real SAPI uploads). PHP's bracket-notation for nested fields (`tags[]`, `user[name]`, `files[0]`) is honored for both parameters and files, delegating the nesting semantics to `parse_str()` via `http_build_query()`. `UploadedFile` instances are injected into the parsed structure after the round-trip since `parse_str()` only preserves scalars. Three new tests cover the end-to-end pipeline: - single file: client name + size + raw content visible server-side - binary content: bytes including 0xFF, 0xFE, 0xC0, 0x00, 0x0A, 0x0D survive intact (regression guard against any future mb_* usage) - mixed: file + non-file fields in the same request, both visible Real-world motivation: Filament v4 FileUpload (FilePond) + Livewire batch import flow could not be tested via this plugin because Livewire's `_finishUpload($name, $tmpPath = [], false)` received an empty array and threw `Undefined array key 0`. The FilePond JS bridge correctly POSTs the multipart payload; the server just dropped the file. This patch makes that pipeline testable end-to-end. --- src/Drivers/LaravelHttpServer.php | 170 +++++++++++++++++- .../Browser/Webpage/AttachServerSideTest.php | 146 +++++++++++++++ 2 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 tests/Browser/Webpage/AttachServerSideTest.php diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index 97ae5fdb..d3410f5a 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -25,6 +25,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; @@ -241,8 +242,14 @@ private function handleRequest(AmpRequest $request): Response $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') { + $lcContentType = mb_strtolower($contentType); + if (str_starts_with($lcContentType, 'application/x-www-form-urlencoded')) { + parse_str($rawBody, $parameters); + } elseif (str_starts_with($lcContentType, 'multipart/form-data')) { + [$parameters, $files] = $this->parseMultipartBody($rawBody, $contentType); + } } $cookies = array_map(fn (RequestCookie $cookie): string => urldecode($cookie->getValue()), $request->getCookies()); $cookies = array_merge($cookies, test()->prepareCookiesForRequest()); // @phpstan-ignore-line @@ -254,7 +261,7 @@ private function handleRequest(AmpRequest $request): Response $method, $parameters, $cookies, - [], // @TODO files... + $files, $serverVariables, $rawBody ); @@ -312,6 +319,163 @@ private function handleRequest(AmpRequest $request): Response ); } + /** + * Parse a `multipart/form-data` body into ($parameters, $files) suitable + * for `Symfony\Component\HttpFoundation\Request::create()`. + * + * Byte-safe — uses string functions (not `mb_*`) so binary uploads + * (.xlsx, .zip, images, PDFs) survive without UTF-8 corruption. Each + * file part is written to a temp file in `sys_get_temp_dir()` and + * wrapped in a Symfony `UploadedFile` constructed with `$test = true` + * to bypass `is_uploaded_file()` (which only returns true for real + * SAPI uploads). + * + * @return array{0: array, 1: array} + * [0] parameters (non-file form fields), [1] files (UploadedFile + * instances keyed by field name). + */ + private function parseMultipartBody(string $body, string $contentType): array + { + if (preg_match('/boundary="?([^";\s]+)"?/i', $contentType, $matches) !== 1) { + return [[], []]; + } + $boundary = '--'.$matches[1]; + + // Split byte-safe; first chunk is empty (preamble before first boundary). + $parts = explode("\r\n".$boundary, "\r\n".$body); + array_shift($parts); + + $parameters = []; + $files = []; + + foreach ($parts as $part) { + // Closing boundary is "--{boundary}--"; the leading "--" remains + // at the start of $part after split — skip it. + if (str_starts_with($part, '--')) { + continue; + } + // Strip leading "\r\n" left by the split. + if (str_starts_with($part, "\r\n")) { + $part = substr($part, 2); + } + + $headerEnd = strpos($part, "\r\n\r\n"); + if ($headerEnd === false) { + continue; + } + $headerSection = substr($part, 0, $headerEnd); + $content = substr($part, $headerEnd + 4); + + // Trailing "\r\n" precedes the next boundary delimiter. + if (str_ends_with($content, "\r\n")) { + $content = substr($content, 0, -2); + } + + $headers = []; + foreach (explode("\r\n", $headerSection) as $line) { + $colonPos = strpos($line, ':'); + if ($colonPos === false) { + continue; + } + $name = strtolower(trim(substr($line, 0, $colonPos))); + $value = trim(substr($line, $colonPos + 1)); + $headers[$name] = $value; + } + + $disposition = $headers['content-disposition'] ?? ''; + if (preg_match('/name="([^"]+)"/', $disposition, $nameMatch) !== 1) { + continue; + } + $fieldName = $nameMatch[1]; + + if (preg_match('/filename="([^"]*)"/', $disposition, $filenameMatch) === 1) { + $filename = $filenameMatch[1]; + if ($filename === '') { + // Empty `` with no selection. + continue; + } + $mimeType = $headers['content-type'] ?? 'application/octet-stream'; + + $tmpPath = tempnam(sys_get_temp_dir(), 'pest_browser_upload_'); + if ($tmpPath === false) { + continue; + } + file_put_contents($tmpPath, $content); + + $uploadedFile = new UploadedFile( + $tmpPath, + $filename, + $mimeType, + UPLOAD_ERR_OK, + test: true, + ); + + $this->assignParsedValue($files, $fieldName, $uploadedFile); + } else { + $this->assignParsedValue($parameters, $fieldName, $content); + } + } + + return [$parameters, $files]; + } + + /** + * Assign a parsed multipart value into the target array, honoring + * PHP's bracket notation for nested fields (e.g. `tags[]`, `user[name]`, + * `files[0]`). Delegates to `parse_str()` via `http_build_query()` so + * the same nesting semantics PHP applies to `$_POST`/`$_FILES` apply + * here. + * + * @param array $target + */ + private function assignParsedValue(array &$target, string $fieldName, mixed $value): void + { + if (! str_contains($fieldName, '[')) { + $target[$fieldName] = $value; + + return; + } + + // For files we cannot round-trip through http_build_query() — encode + // a placeholder, parse the structure, then walk the parsed tree and + // replace the placeholder leaf with the UploadedFile instance. + $placeholder = '__pest_browser_placeholder_'.spl_object_id((object) []).'__'; + $encoded = http_build_query([$fieldName => $value instanceof UploadedFile ? $placeholder : $value]); + $decoded = []; + parse_str($encoded, $decoded); + + if ($value instanceof UploadedFile) { + $this->replacePlaceholderLeaf($decoded, $placeholder, $value); + } + + $target = array_replace_recursive($target, $decoded); + } + + /** + * Walk an array recursively, replacing the first leaf string equal to + * `$placeholder` with `$replacement`. Used to inject `UploadedFile` + * instances into the structure produced by `parse_str()` (which only + * preserves scalars). + * + * @param array $array + */ + private function replacePlaceholderLeaf(array &$array, string $placeholder, mixed $replacement): bool + { + foreach ($array as $key => &$value) { + if (is_array($value)) { + if ($this->replacePlaceholderLeaf($value, $placeholder, $replacement)) { + return true; + } + } elseif ($value === $placeholder) { + $array[$key] = $replacement; + + return true; + } + } + + return false; + } + /** * Return an asset response. */ diff --git a/tests/Browser/Webpage/AttachServerSideTest.php b/tests/Browser/Webpage/AttachServerSideTest.php new file mode 100644 index 00000000..12797db4 --- /dev/null +++ b/tests/Browser/Webpage/AttachServerSideTest.php @@ -0,0 +1,146 @@ + Playwright::setTimeout(10000)); +afterEach(fn () => Playwright::setTimeout(2000)); + +it('forwards a single uploaded file to the Laravel request', function (): void { + Route::post('/upload', function (Request $request): string { + $file = $request->file('avatar'); + if ($file === null) { + return 'NO_FILE'; + } + + return sprintf( + '%s|%s|%d|%s', + $file->getClientOriginalName(), + $file->getClientMimeType(), + $file->getSize(), + $file->getContent(), + ); + })->withoutMiddleware([ValidateCsrfToken::class]); + + Route::get('/form', fn (): string => ' +
+ +
+
+ + + '); + + $tempFile = tempnam(sys_get_temp_dir(), 'pest_upload_'); + file_put_contents($tempFile, 'hello, server'); + + // Note: client mime type comes from the browser (FormData picks a guess + // based on the file extension, which is none for tempnam). Assert + // separately on name + size + raw content; the MIME-type assertion is + // skipped because it is environment-dependent. + visit('/form') + ->attach('#avatar', $tempFile) + ->click('#submit') + ->assertSee(basename($tempFile)) + ->assertSee('13') // bytes of "hello, server" + ->assertSee('hello, server'); + + unlink($tempFile); +}); + +it('preserves binary file content (no UTF-8 corruption)', function (): void { + Route::post('/upload-binary', function (Request $request): string { + $file = $request->file('payload'); + if ($file === null) { + return 'NO_FILE'; + } + + return bin2hex($file->getContent()); + })->withoutMiddleware([ValidateCsrfToken::class]); + + Route::get('/form-binary', fn (): string => ' +
+ +
+
+ + + '); + + // Bytes that would corrupt under mb_* string operations: 0xFF, 0xFE, + // 0xC0 (invalid UTF-8 lead byte), 0x00 (null), 0x0A (LF), 0x0D (CR). + $binary = "\xFF\xFE\xC0\x00\x0A\x0D\x80\x81"; + $expectedHex = bin2hex($binary); + + $tempFile = tempnam(sys_get_temp_dir(), 'pest_binary_'); + file_put_contents($tempFile, $binary); + + visit('/form-binary') + ->attach('#payload', $tempFile) + ->click('#submit') + ->assertSee($expectedHex); + + unlink($tempFile); +}); + +it('mixes file and non-file fields in the same multipart request', function (): void { + Route::post('/upload-mixed', function (Request $request): string { + $file = $request->file('avatar'); + $note = $request->input('note'); + + return sprintf( + 'file=%s note=%s', + $file !== null ? $file->getClientOriginalName() : 'NULL', + $note ?? 'NULL', + ); + })->withoutMiddleware([ValidateCsrfToken::class]); + + Route::get('/form-mixed', fn (): string => ' +
+ + +
+
+ + + '); + + $tempFile = tempnam(sys_get_temp_dir(), 'pest_mixed_'); + file_put_contents($tempFile, 'x'); + + visit('/form-mixed') + ->fill('#note', 'remember-me') + ->attach('#avatar', $tempFile) + ->click('#submit') + ->assertSee('remember-me') + ->assertSee(basename($tempFile)); + + unlink($tempFile); +});