Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 167 additions & 3 deletions src/Drivers/LaravelHttpServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -254,7 +261,7 @@ private function handleRequest(AmpRequest $request): Response
$method,
$parameters,
$cookies,
[], // @TODO files...
$files,
$serverVariables,
$rawBody
);
Expand Down Expand Up @@ -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<string, mixed>, 1: array<string, mixed>}
* [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 `<input type="file">` 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<string, mixed> $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<int|string, mixed> $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.
*/
Expand Down
146 changes: 146 additions & 0 deletions tests/Browser/Webpage/AttachServerSideTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

declare(strict_types=1);

use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Pest\Browser\Playwright\Playwright;

// The defaults in tests/Pest.php (2000ms) are too short for round-trips
// that include form submit + server-side file processing on slower CI.
beforeEach(fn () => 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 => '
<form id="upload-form" method="POST" action="/upload" enctype="multipart/form-data">
<input type="file" id="avatar" name="avatar">
</form>
<div id="result"></div>
<script>
document.getElementById("upload-form").addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const res = await fetch(e.target.action, { method: "POST", body: fd });
document.getElementById("result").textContent = await res.text();
});
</script>
<button id="submit" onclick="document.getElementById(\'upload-form\').dispatchEvent(new Event(\'submit\'))">Send</button>
');

$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 => '
<form id="upload-form" method="POST" action="/upload-binary" enctype="multipart/form-data">
<input type="file" id="payload" name="payload">
</form>
<div id="result"></div>
<script>
document.getElementById("upload-form").addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const res = await fetch(e.target.action, { method: "POST", body: fd });
document.getElementById("result").textContent = await res.text();
});
</script>
<button id="submit" onclick="document.getElementById(\'upload-form\').dispatchEvent(new Event(\'submit\'))">Send</button>
');

// 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 => '
<form id="upload-form" method="POST" action="/upload-mixed" enctype="multipart/form-data">
<input type="text" id="note" name="note" value="">
<input type="file" id="avatar" name="avatar">
</form>
<div id="result"></div>
<script>
document.getElementById("upload-form").addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const res = await fetch(e.target.action, { method: "POST", body: fd });
document.getElementById("result").textContent = await res.text();
});
</script>
<button id="submit" onclick="document.getElementById(\'upload-form\').dispatchEvent(new Event(\'submit\'))">Send</button>
');

$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);
});