From 43fd2d845e44a23164876b5af4231c132d4e43a0 Mon Sep 17 00:00:00 2001 From: Llewdur Date: Fri, 21 Aug 2026 14:12:21 +0200 Subject: [PATCH] fix: parse multipart/form-data request bodies in LaravelHttpServer `handleRequest()` only understood `application/x-www-form-urlencoded` bodies, so a real file upload driven through the browser (a `` set via a `File`/`DataTransfer`, or any `FormData` submit) reached the in-process test server as an unparsed multipart body: no fields, no files, and `Request::create()` was always given `[]` for its files argument (the `// @TODO files...`). Add `Support\MultipartFormDataParser`, a small, framework-agnostic parser that turns a raw multipart body into the `[$parameters, $files]` shape `Symfony\Component\HttpFoundation\Request::create()` expects. Uploaded parts are written to temp files and described in the same `$_FILES`-style array PHP itself produces, including for bracketed field names (`documents[]`) - `FileBag::fixPhpFilesArray()` normalizes either shape, so this doesn't need to special-case it. An empty file input (no file chosen) is reported as `UPLOAD_ERR_NO_FILE`, matching a real PHP SAPI. Verified directly against `symfony/http-foundation`'s `Request::create()` and `FileBag`: bracketed multi-file fields convert into a list of real `UploadedFile` instances with the right original names and on-disk content. --- src/Drivers/LaravelHttpServer.php | 9 +- src/Support/MultipartFormDataParser.php | 223 ++++++++++++++++++ .../Support/MultipartFormDataParserTest.php | 111 +++++++++ 3 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 src/Support/MultipartFormDataParser.php create mode 100644 tests/Unit/Support/MultipartFormDataParserTest.php diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index 97ae5fdb..593612b4 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -24,6 +24,7 @@ use Pest\Browser\Execution; use Pest\Browser\GlobalState; use Pest\Browser\Playwright\Playwright; +use Pest\Browser\Support\MultipartFormDataParser; use Psr\Log\NullLogger; use Symfony\Component\Mime\MimeTypes; use Throwable; @@ -241,8 +242,12 @@ 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')) { + $files = []; + $normalizedContentType = mb_strtolower($contentType); + if ($method !== 'GET' && str_starts_with($normalizedContentType, 'application/x-www-form-urlencoded')) { parse_str($rawBody, $parameters); + } elseif ($method !== 'GET' && str_starts_with($normalizedContentType, 'multipart/form-data')) { + [$parameters, $files] = MultipartFormDataParser::parse($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 +259,7 @@ private function handleRequest(AmpRequest $request): Response $method, $parameters, $cookies, - [], // @TODO files... + $files, $serverVariables, $rawBody ); diff --git a/src/Support/MultipartFormDataParser.php b/src/Support/MultipartFormDataParser.php new file mode 100644 index 00000000..fe4e6386 --- /dev/null +++ b/src/Support/MultipartFormDataParser.php @@ -0,0 +1,223 @@ +, 1: array} + */ + public static function parse(string $body, string $contentType): array + { + $parameters = []; + $files = []; + + $boundary = self::boundary($contentType); + + if ($boundary === null) { + return [$parameters, $files]; + } + + foreach (self::parts($body, $boundary) as [$headers, $content]) { + $disposition = $headers['content-disposition'] ?? ''; + + if (preg_match('/name="(?[^"]*)"/', $disposition, $nameMatch) !== 1) { + continue; + } + + $path = self::path($nameMatch['name']); + + if (preg_match('/filename="(?[^"]*)"/', $disposition, $filenameMatch) === 1) { + self::setFile($files, $path, $filenameMatch['filename'], $headers['content-type'] ?? null, $content); + + continue; + } + + self::set($parameters, $path, $content); + } + + return [$parameters, $files]; + } + + /** + * Extracts the boundary token from a `multipart/form-data` content type header. + */ + private static function boundary(string $contentType): ?string + { + if (preg_match('/boundary=(?:"(?[^"]+)"|(?[^;]+))/', $contentType, $matches) !== 1) { + return null; + } + + $boundary = $matches['quoted'] !== '' ? $matches['quoted'] : $matches['bare']; + + return rtrim($boundary); + } + + /** + * Splits the raw body into `[headers, content]` pairs, one per part. + * + * @return list, 1: string}> + */ + private static function parts(string $body, string $boundary): array + { + $segments = explode('--'.$boundary, $body); + + // The first segment is the preamble before the first boundary, and the + // last is whatever follows the closing `--boundary--`; neither is a part. + array_shift($segments); + array_pop($segments); + + $parts = []; + + foreach ($segments as $segment) { + $segment = ltrim($segment, "\r\n"); + $headerEnd = strpos($segment, "\r\n\r\n"); + + if ($headerEnd === false) { + continue; + } + + $content = substr($segment, $headerEnd + 4); + + if (str_ends_with($content, "\r\n")) { + $content = substr($content, 0, -2); + } + + $parts[] = [self::headers(substr($segment, 0, $headerEnd)), $content]; + } + + return $parts; + } + + /** + * Parses a block of `Header: value` lines into a lower-cased, associative array. + * + * @return array + */ + private static function headers(string $rawHeaders): array + { + $headers = []; + + foreach (explode("\r\n", $rawHeaders) as $line) { + if (! str_contains($line, ':')) { + continue; + } + + [$key, $value] = explode(':', $line, 2); + + $headers[strtolower(trim($key))] = trim($value); + } + + return $headers; + } + + /** + * Splits a field name like `documents[a][]` into `['documents', 'a', '']`, + * mirroring how PHP itself parses bracketed form field names. + * + * @return list + */ + private static function path(string $name): array + { + if (preg_match('/^(?[^\[\]]+)(?(?:\[[^\]]*\])*)$/', $name, $matches) !== 1) { + return [$name]; + } + + $path = [$matches['root']]; + + if ($matches['rest'] !== '') { + preg_match_all('/\[([^\]]*)\]/', $matches['rest'], $segments); + + array_push($path, ...$segments[1]); + } + + return $path; + } + + /** + * Assigns `$value` at the nested location described by `$path`, appending + * a new element whenever a path segment is the empty string (`foo[]`). + * + * @param array $target + * @param list $path + */ + private static function set(array &$target, array $path, mixed $value): void + { + $key = array_shift($path); + + if ($key === '') { + if ($path === []) { + $target[] = $value; + + return; + } + + $target[] = []; + self::set($target[array_key_last($target)], $path, $value); + + return; + } + + if ($path === []) { + $target[$key] = $value; + + return; + } + + if (! isset($target[$key]) || ! is_array($target[$key])) { + $target[$key] = []; + } + + self::set($target[$key], $path, $value); + } + + /** + * Writes an uploaded file's content to a temporary file and assigns its + * `$_FILES`-shaped descriptor at the nested location described by `$path`. + * + * @param array $files + * @param list $path + */ + private static function setFile(array &$files, array $path, string $filename, ?string $type, string $content): void + { + if ($filename === '') { + self::set($files, $path, [ + 'name' => '', + 'type' => '', + 'tmp_name' => '', + 'error' => \UPLOAD_ERR_NO_FILE, + 'size' => 0, + ]); + + return; + } + + $tmpName = tempnam(sys_get_temp_dir(), 'pest_upload_'); + + if ($tmpName === false) { + return; + } + + file_put_contents($tmpName, $content); + + self::set($files, $path, [ + 'name' => $filename, + 'type' => $type ?? 'application/octet-stream', + 'tmp_name' => $tmpName, + 'error' => \UPLOAD_ERR_OK, + 'size' => strlen($content), + ]); + } +} diff --git a/tests/Unit/Support/MultipartFormDataParserTest.php b/tests/Unit/Support/MultipartFormDataParserTest.php new file mode 100644 index 00000000..01928a2b --- /dev/null +++ b/tests/Unit/Support/MultipartFormDataParserTest.php @@ -0,0 +1,111 @@ +toBe([ + 'type' => 'municipal_valuation', + 'notes' => "multi-line\r\nvalue", + ])->and($files)->toBe([]); +}); + +it('parses a single uploaded file into a Symfony-compatible descriptor', function (): void { + $boundary = 'Boundary123'; + $content = "%PDF-1.4\nfake pdf bytes"; + $body = multipartBody($boundary, [ + "--{$boundary}", + 'Content-Disposition: form-data; name="type"', + '', + 'municipal_valuation', + "--{$boundary}", + 'Content-Disposition: form-data; name="file"; filename="valuation.pdf"', + 'Content-Type: application/pdf', + '', + $content, + ]); + + [$parameters, $files] = MultipartFormDataParser::parse($body, "multipart/form-data; boundary={$boundary}"); + + expect($parameters)->toBe(['type' => 'municipal_valuation']) + ->and($files)->toHaveKey('file') + ->and($files['file']['name'])->toBe('valuation.pdf') + ->and($files['file']['type'])->toBe('application/pdf') + ->and($files['file']['error'])->toBe(\UPLOAD_ERR_OK) + ->and($files['file']['size'])->toBe(strlen($content)) + ->and(file_get_contents($files['file']['tmp_name']))->toBe($content); +}); + +it('marks an empty file input as UPLOAD_ERR_NO_FILE', function (): void { + $boundary = 'Boundary123'; + $body = multipartBody($boundary, [ + "--{$boundary}", + 'Content-Disposition: form-data; name="avatar"; filename=""', + 'Content-Type: application/octet-stream', + '', + '', + ]); + + [, $files] = MultipartFormDataParser::parse($body, "multipart/form-data; boundary={$boundary}"); + + expect($files['avatar']['error'])->toBe(\UPLOAD_ERR_NO_FILE) + ->and($files['avatar']['tmp_name'])->toBe(''); +}); + +it('collects repeated bracketed fields into a list, mirroring PHP\'s own form parsing', function (): void { + $boundary = 'Boundary123'; + $body = multipartBody($boundary, [ + "--{$boundary}", + 'Content-Disposition: form-data; name="tags[]"', + '', + 'roof', + "--{$boundary}", + 'Content-Disposition: form-data; name="tags[]"', + '', + 'damp', + "--{$boundary}", + 'Content-Disposition: form-data; name="documents[]"; filename="a.png"', + 'Content-Type: image/png', + '', + 'aaa', + "--{$boundary}", + 'Content-Disposition: form-data; name="documents[]"; filename="b.png"', + 'Content-Type: image/png', + '', + 'bbb', + ]); + + [$parameters, $files] = MultipartFormDataParser::parse($body, "multipart/form-data; boundary={$boundary}"); + + expect($parameters)->toBe(['tags' => ['roof', 'damp']]) + ->and($files['documents'])->toHaveCount(2) + ->and($files['documents'][0]['name'])->toBe('a.png') + ->and($files['documents'][1]['name'])->toBe('b.png'); +}); + +it('returns empty parameters and files when the content type has no boundary', function (): void { + [$parameters, $files] = MultipartFormDataParser::parse('irrelevant', 'multipart/form-data'); + + expect($parameters)->toBe([])->and($files)->toBe([]); +});