From 60b0e44df2f3a9142c80080300e41d5760630341 Mon Sep 17 00:00:00 2001 From: Pierre SOUVIGNET Date: Fri, 25 Sep 2026 08:15:37 +0200 Subject: [PATCH 1/3] feat: migrate to php8.5 URI --- .../Security/InteractsWithTwoFactorAuth.php | 4 ++- src/Phaseolies/Http/Request.php | 19 ++++++----- .../Http/Response/RedirectResponse.php | 33 +++---------------- src/Phaseolies/Launchers/CacheLauncher.php | 12 ++++--- src/Phaseolies/Launchers/RouteLauncher.php | 6 ++-- src/Phaseolies/Support/ViteManager.php | 11 ++++--- src/Phaseolies/Utilities/Paginator.php | 27 ++++++++------- tests/PaginatorTest.php | 22 +++++++++++++ tests/RedirectResponseTest.php | 21 ++++++++++++ tests/Requests/RequestTest.php | 31 +++++++++++++++++ tests/Support/ViteManagerTest.php | 9 +++++ 11 files changed, 132 insertions(+), 63 deletions(-) diff --git a/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php b/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php index 1e937442..6a1912a1 100644 --- a/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php +++ b/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php @@ -12,6 +12,7 @@ use Phaseolies\Auth\Authable; use ParagonIE\ConstantTime\Base32; use OTPHP\TOTP; +use Uri\Rfc3986\Uri; trait InteractsWithTwoFactorAuth { @@ -49,7 +50,8 @@ public function enableTwoFactorAuth(): array $this->getClock() ); - $host = parse_url(config('app.url'), PHP_URL_HOST); + $appUri = Uri::parse((string) config('app.url')); + $host = $appUri?->getHost() ?? ''; $issuer = preg_replace('/[^a-zA-Z0-9.\-_]/', '', $host); $totp->setLabel(strtolower(trim(config('app.name')))); diff --git a/src/Phaseolies/Http/Request.php b/src/Phaseolies/Http/Request.php index bcc928e0..a9cb3423 100644 --- a/src/Phaseolies/Http/Request.php +++ b/src/Phaseolies/Http/Request.php @@ -17,6 +17,7 @@ use Phaseolies\Http\ParameterBag; use Phaseolies\Http\InputBag; use Phaseolies\Http\HeaderBag; +use Uri\Rfc3986\Uri; /** * @phpstan-consistent-constructor @@ -629,9 +630,9 @@ public function merge(array $input): self */ public function getPath(): string { - return urldecode( - parse_url($this->server->get("REQUEST_URI", "/"), PHP_URL_PATH) - ); + $uri = Uri::parse($this->server->get("REQUEST_URI", "/")); + + return urldecode($uri?->getRawPath() ?? '/'); } /** @@ -1171,14 +1172,14 @@ protected function prepareRequestUri(): string } else { // HTTP proxy reqs setup request URI with scheme and host [and port] + the URL path, // only use URL path. - $uriComponents = parse_url($requestUri); + $uriComponents = Uri::parse($requestUri); - if (isset($uriComponents['path'])) { - $requestUri = $uriComponents['path']; - } + if ($uriComponents !== null) { + $requestUri = $uriComponents->getRawPath(); - if (isset($uriComponents['query'])) { - $requestUri .= '?' . $uriComponents['query']; + if ($uriComponents->getRawQuery() !== null) { + $requestUri .= '?' . $uriComponents->getRawQuery(); + } } } } elseif ($this->server->has('ORIG_PATH_INFO')) { diff --git a/src/Phaseolies/Http/Response/RedirectResponse.php b/src/Phaseolies/Http/Response/RedirectResponse.php index 57a552cf..4ce69245 100644 --- a/src/Phaseolies/Http/Response/RedirectResponse.php +++ b/src/Phaseolies/Http/Response/RedirectResponse.php @@ -6,6 +6,7 @@ use Phaseolies\Session\MessageBag; use Phaseolies\Http\Response; use Phaseolies\Support\Facades\Str; +use Uri\Rfc3986\Uri; class RedirectResponse extends Response { @@ -139,35 +140,11 @@ protected function ensureScheme(string $url, bool $secure): string { // If the URL is already absolute (contains ://), parse it if (strpos($url, '://') !== false) { - $parsedUrl = parse_url($url); + $parsedUrl = Uri::parse($url); - // Rebuild the URL with the new scheme - $scheme = $secure ? 'https' : 'http'; - $url = $scheme . '://'; - - // Add the host if it exists - if (isset($parsedUrl['host'])) { - $url .= $parsedUrl['host']; - } - - // Add the port if it exists - if (isset($parsedUrl['port'])) { - $url .= ':' . $parsedUrl['port']; - } - - // Add the path if it exists - if (isset($parsedUrl['path'])) { - $url .= $parsedUrl['path']; - } - - // Add the query string if it exists - if (isset($parsedUrl['query'])) { - $url .= '?' . $parsedUrl['query']; - } - - // Add the fragment if it exists - if (isset($parsedUrl['fragment'])) { - $url .= '#' . $parsedUrl['fragment']; + if ($parsedUrl !== null) { + $scheme = $secure ? 'https' : 'http'; + $url = $parsedUrl->withScheme($scheme)->toRawString(); } } else { // For relative URLs, prepend the current host and scheme diff --git a/src/Phaseolies/Launchers/CacheLauncher.php b/src/Phaseolies/Launchers/CacheLauncher.php index 235a3974..9a0b6213 100644 --- a/src/Phaseolies/Launchers/CacheLauncher.php +++ b/src/Phaseolies/Launchers/CacheLauncher.php @@ -2,6 +2,7 @@ namespace Phaseolies\Launchers; +use Uri\Rfc3986\Uri; use Symfony\Component\Cache\Adapter\RedisAdapter; use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Adapter\ArrayAdapter; @@ -71,12 +72,13 @@ protected function createRedisAdapter(array $config): RedisAdapter $redis = new \Redis(); $dsn = $config['connection'] ?? 'redis://127.0.0.1:6379'; - $parsed = parse_url($dsn); + $parsed = Uri::parse($dsn); - $host = $parsed['host'] ?? '127.0.0.1'; - $port = $parsed['port'] ?? 6379; - $password = $parsed['pass'] ?? null; - $database = isset($parsed['path']) ? (int) substr($parsed['path'], 1) : 0; + $host = $parsed?->getHost() ?? '127.0.0.1'; + $port = $parsed?->getPort() ?? 6379; + $password = $parsed?->getPassword(); + $path = $parsed?->getRawPath() ?? ''; + $database = $path !== '' ? (int) substr($path, 1) : 0; if (!$redis->connect($host, $port, 2.5)) { throw new \RuntimeException("Could not connect to Redis at {$host}:{$port}"); diff --git a/src/Phaseolies/Launchers/RouteLauncher.php b/src/Phaseolies/Launchers/RouteLauncher.php index d054ddc5..38ce393f 100644 --- a/src/Phaseolies/Launchers/RouteLauncher.php +++ b/src/Phaseolies/Launchers/RouteLauncher.php @@ -3,6 +3,7 @@ namespace Phaseolies\Launchers; use Phaseolies\Support\Facades\Route; +use Uri\Rfc3986\Uri; class RouteLauncher extends ServiceLauncher { @@ -13,9 +14,8 @@ class RouteLauncher extends ServiceLauncher */ public function register() { - $path = urldecode( - parse_url(request()->server->get("REQUEST_URI", "/"), PHP_URL_PATH) - ); + $uri = Uri::parse(request()->server->get("REQUEST_URI", "/")); + $path = urldecode($uri?->getRawPath() ?? '/'); if ($path !== '/' && str_ends_with(request()->server->get('REQUEST_URI'), '/')) { header('Location: ' . rtrim(request()->server->get('REQUEST_URI'), '/'), true, 301); diff --git a/src/Phaseolies/Support/ViteManager.php b/src/Phaseolies/Support/ViteManager.php index b4aaf70a..15a0fba3 100644 --- a/src/Phaseolies/Support/ViteManager.php +++ b/src/Phaseolies/Support/ViteManager.php @@ -3,6 +3,7 @@ namespace Phaseolies\Support; use RuntimeException; +use Uri\Rfc3986\Uri; class ViteManager { @@ -128,15 +129,15 @@ protected function hotUrl(): string */ protected function hotServerIsReachable(string $url): bool { - $parts = parse_url($url); + $parts = Uri::parse($url); + $host = $parts?->getHost(); - if (!is_array($parts) || empty($parts['host'])) { + if ($host === null || $host === '') { return false; } - $scheme = strtolower($parts['scheme'] ?? 'http'); - $host = $parts['host']; - $port = (int) ($parts['port'] ?? ($scheme === 'https' ? 443 : 80)); + $scheme = strtolower($parts->getScheme() ?? 'http'); + $port = $parts->getPort() ?? ($scheme === 'https' ? 443 : 80); $transport = $scheme === 'https' ? 'ssl' : 'tcp'; $connection = @stream_socket_client( diff --git a/src/Phaseolies/Utilities/Paginator.php b/src/Phaseolies/Utilities/Paginator.php index a1a4392a..60f222f6 100644 --- a/src/Phaseolies/Utilities/Paginator.php +++ b/src/Phaseolies/Utilities/Paginator.php @@ -2,6 +2,8 @@ namespace Phaseolies\Utilities; +use Uri\Rfc3986\Uri; + class Paginator { /** @@ -341,26 +343,27 @@ protected function appendQueryParameters(?string $url, array $queryParams): stri return ''; } - // Parse the URL to get its components - $parsedUrl = parse_url($url); + $parsedUrl = Uri::parse($url); + + if ($parsedUrl === null) { + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url . $separator . http_build_query($queryParams); + } $existingParams = []; - // Get existing query parameters from the URL - if (isset($parsedUrl['query'])) { - parse_str($parsedUrl['query'], $existingParams); + if ($parsedUrl->getRawQuery() !== null) { + parse_str($parsedUrl->getRawQuery(), $existingParams); } - // Merge with new parameters // New ones take precedence $mergedParams = array_merge($queryParams, $existingParams); - - // Rebuild the URL without modifying the base URL - $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : ''; - $host = $parsedUrl['host'] ?? ''; - $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : ''; - $path = $parsedUrl['path'] ?? ''; $query = http_build_query($mergedParams); + $scheme = $parsedUrl->getRawScheme() !== null ? $parsedUrl->getRawScheme() . '://' : ''; + $host = $parsedUrl->getRawHost() ?? ''; + $port = $parsedUrl->getPort() !== null ? ':' . $parsedUrl->getPort() : ''; + $path = $parsedUrl->getRawPath(); return $scheme . $host . $port . $path . '?' . $query; } diff --git a/tests/PaginatorTest.php b/tests/PaginatorTest.php index cf553937..90fb5cfa 100644 --- a/tests/PaginatorTest.php +++ b/tests/PaginatorTest.php @@ -113,6 +113,28 @@ public function testUrlGeneration() $this->assertEquals('http://example.com?page=3', $this->paginator->url(3)); } + public function testAppendQueryParametersPreservesExistingUriComponents(): void + { + $method = new \ReflectionMethod($this->paginator, 'appendQueryParameters'); + + $result = $method->invoke( + $this->paginator, + 'https://example.com/items?filter=old&sort=asc', + ['page' => 3, 'filter' => 'new'] + ); + + $this->assertSame('https://example.com/items?page=3&filter=old&sort=asc', $result); + } + + public function testAppendQueryParametersFallsBackForInvalidUri(): void + { + $method = new \ReflectionMethod($this->paginator, 'appendQueryParameters'); + + $result = $method->invoke($this->paginator, 'http://[invalid', ['page' => 2]); + + $this->assertSame('http://[invalid?page=2', $result); + } + public function testJumpMethod() { $expected = [1, '...', 3, 4, 5, 6, 7, '...', 10]; diff --git a/tests/RedirectResponseTest.php b/tests/RedirectResponseTest.php index 59fcaa00..96bebbd0 100644 --- a/tests/RedirectResponseTest.php +++ b/tests/RedirectResponseTest.php @@ -268,6 +268,27 @@ public function testToMethodWithSecureFalse() $this->assertEquals('http://example.com/profile', $location); } + public function testToMethodWithSecureTruePreservesEncodedComponents(): void + { + $url = 'http://example.com/path%2Fitem?token=a%2Fb#section%2Fone'; + + $this->redirect->to($url, 302, [], true); + + $this->assertSame( + 'https://example.com/path%2Fitem?token=a%2Fb#section%2Fone', + $this->redirect->headers->get('Location') + ); + } + + public function testToMethodPreservesInvalidAbsoluteUrlWhenForcingScheme(): void + { + $url = 'http://[invalid'; + + $this->redirect->to($url, 302, [], true); + + $this->assertSame($url, $this->redirect->headers->get('Location')); + } + public function testBackMethodWithReferer() { $referer = 'https://example.com/previous'; diff --git a/tests/Requests/RequestTest.php b/tests/Requests/RequestTest.php index 67b6685b..b390b38e 100644 --- a/tests/Requests/RequestTest.php +++ b/tests/Requests/RequestTest.php @@ -195,6 +195,37 @@ public function testGetPath() $this->assertEquals('/test', $this->request->getPath()); } + public function testGetPathPreservesPathAndIgnoresQuery(): void + { + $this->request->server->set('REQUEST_URI', '/products/item%20one?filter=active'); + + $this->assertSame('/products/item one', $this->request->getPath()); + } + + public function testPrepareRequestUriExtractsProxyPathAndQuery(): void + { + $request = new Request([], [], [], [], [], [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'https://proxy.example/products/item%2Fone?filter=active#section', + ]); + $property = new \ReflectionProperty(Request::class, 'requestUri'); + $property->setValue($request, null); + + $this->assertSame('/products/item%2Fone?filter=active', $request->getRequestUri()); + } + + public function testPrepareRequestUriRetainsInvalidProxyUri(): void + { + $request = new Request([], [], [], [], [], [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'not a valid uri', + ]); + $property = new \ReflectionProperty(Request::class, 'requestUri'); + $property->setValue($request, null); + + $this->assertSame('not a valid uri', $request->getRequestUri()); + } + public function testGetMethod() { $this->assertEquals('GET', $this->request->getMethod()); diff --git a/tests/Support/ViteManagerTest.php b/tests/Support/ViteManagerTest.php index 3431a88c..604cd0da 100644 --- a/tests/Support/ViteManagerTest.php +++ b/tests/Support/ViteManagerTest.php @@ -194,6 +194,15 @@ public function testManifestPathAcceptsWindowsStyleBuildDirectory(): void ); } + public function testHotServerIsNotReachableForInvalidUri(): void + { + $manager = new ViteManager(); + $method = new \ReflectionMethod($manager, 'hotServerIsReachable'); + + $this->assertFalse($method->invoke($manager, 'http://[invalid')); + $this->assertFalse($method->invoke($manager, '/relative/path')); + } + private function deleteDirectory(string $directory): void { if (!is_dir($directory)) { From 2201676681537531a8150f3d33d5571c4eb9e850 Mon Sep 17 00:00:00 2001 From: Pierre SOUVIGNET Date: Fri, 25 Sep 2026 11:18:32 +0200 Subject: [PATCH 2/3] fixed review --- src/Phaseolies/Http/Request.php | 7 +++- .../Http/Response/RedirectResponse.php | 4 +- src/Phaseolies/Launchers/CacheLauncher.php | 2 +- src/Phaseolies/Launchers/RouteLauncher.php | 4 +- src/Phaseolies/Utilities/Paginator.php | 34 ++++++++++----- tests/PaginatorTest.php | 32 ++++++++++++++- tests/RedirectResponseTest.php | 13 +++++- tests/Requests/RequestTest.php | 31 ++++++++++---- tests/Router/RouterTest.php | 16 ++++++++ tests/UriMigrationTest.php | 41 +++++++++++++++++++ 10 files changed, 156 insertions(+), 28 deletions(-) create mode 100644 tests/UriMigrationTest.php diff --git a/src/Phaseolies/Http/Request.php b/src/Phaseolies/Http/Request.php index a9cb3423..758389c4 100644 --- a/src/Phaseolies/Http/Request.php +++ b/src/Phaseolies/Http/Request.php @@ -630,9 +630,9 @@ public function merge(array $input): self */ public function getPath(): string { - $uri = Uri::parse($this->server->get("REQUEST_URI", "/")); + $path = parse_url($this->server->get("REQUEST_URI", "/"), PHP_URL_PATH); - return urldecode($uri?->getRawPath() ?? '/'); + return urldecode(is_string($path) ? $path : '/'); } /** @@ -1180,6 +1180,9 @@ protected function prepareRequestUri(): string if ($uriComponents->getRawQuery() !== null) { $requestUri .= '?' . $uriComponents->getRawQuery(); } + } elseif (($uriComponents = parse_url($requestUri)) !== false) { + $requestUri = ($uriComponents['path'] ?? '') + . (isset($uriComponents['query']) ? '?' . $uriComponents['query'] : ''); } } } elseif ($this->server->has('ORIG_PATH_INFO')) { diff --git a/src/Phaseolies/Http/Response/RedirectResponse.php b/src/Phaseolies/Http/Response/RedirectResponse.php index 4ce69245..76dd4538 100644 --- a/src/Phaseolies/Http/Response/RedirectResponse.php +++ b/src/Phaseolies/Http/Response/RedirectResponse.php @@ -141,10 +141,12 @@ protected function ensureScheme(string $url, bool $secure): string // If the URL is already absolute (contains ://), parse it if (strpos($url, '://') !== false) { $parsedUrl = Uri::parse($url); + $scheme = $secure ? 'https' : 'http'; if ($parsedUrl !== null) { - $scheme = $secure ? 'https' : 'http'; $url = $parsedUrl->withScheme($scheme)->toRawString(); + } elseif (parse_url($url) !== false) { + $url = preg_replace('#^[a-z][a-z0-9+.\\-]*://#i', $scheme . '://', $url, 1) ?? $url; } } else { // For relative URLs, prepend the current host and scheme diff --git a/src/Phaseolies/Launchers/CacheLauncher.php b/src/Phaseolies/Launchers/CacheLauncher.php index 9a0b6213..fcc13241 100644 --- a/src/Phaseolies/Launchers/CacheLauncher.php +++ b/src/Phaseolies/Launchers/CacheLauncher.php @@ -74,7 +74,7 @@ protected function createRedisAdapter(array $config): RedisAdapter $dsn = $config['connection'] ?? 'redis://127.0.0.1:6379'; $parsed = Uri::parse($dsn); - $host = $parsed?->getHost() ?? '127.0.0.1'; + $host = $parsed?->getHost() ?: '127.0.0.1'; $port = $parsed?->getPort() ?? 6379; $password = $parsed?->getPassword(); $path = $parsed?->getRawPath() ?? ''; diff --git a/src/Phaseolies/Launchers/RouteLauncher.php b/src/Phaseolies/Launchers/RouteLauncher.php index 38ce393f..ab859ecd 100644 --- a/src/Phaseolies/Launchers/RouteLauncher.php +++ b/src/Phaseolies/Launchers/RouteLauncher.php @@ -3,7 +3,6 @@ namespace Phaseolies\Launchers; use Phaseolies\Support\Facades\Route; -use Uri\Rfc3986\Uri; class RouteLauncher extends ServiceLauncher { @@ -14,8 +13,7 @@ class RouteLauncher extends ServiceLauncher */ public function register() { - $uri = Uri::parse(request()->server->get("REQUEST_URI", "/")); - $path = urldecode($uri?->getRawPath() ?? '/'); + $path = request()->getPath(); if ($path !== '/' && str_ends_with(request()->server->get('REQUEST_URI'), '/')) { header('Location: ' . rtrim(request()->server->get('REQUEST_URI'), '/'), true, 301); diff --git a/src/Phaseolies/Utilities/Paginator.php b/src/Phaseolies/Utilities/Paginator.php index 60f222f6..bb08e78c 100644 --- a/src/Phaseolies/Utilities/Paginator.php +++ b/src/Phaseolies/Utilities/Paginator.php @@ -339,31 +339,43 @@ public function links(): ?string */ protected function appendQueryParameters(?string $url, array $queryParams): string { - if (!$url) { + if ($url === null || $url === '') { return ''; } $parsedUrl = Uri::parse($url); - if ($parsedUrl === null) { - $separator = str_contains($url, '?') ? '&' : '?'; + if ($parsedUrl !== null) { + $scheme = $parsedUrl->getRawScheme() !== null ? $parsedUrl->getRawScheme() . '://' : ''; + $host = $parsedUrl->getRawHost() ?? ''; + $port = $parsedUrl->getPort() !== null ? ':' . $parsedUrl->getPort() : ''; + $path = $parsedUrl->getRawPath(); + $rawQuery = $parsedUrl->getRawQuery(); + } else { + $parsedUrl = parse_url($url); - return $url . $separator . http_build_query($queryParams); + if ($parsedUrl === false) { + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url . $separator . http_build_query($queryParams); + } + + $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : ''; + $host = $parsedUrl['host'] ?? ''; + $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : ''; + $path = $parsedUrl['path'] ?? ''; + $rawQuery = $parsedUrl['query'] ?? null; } $existingParams = []; - if ($parsedUrl->getRawQuery() !== null) { - parse_str($parsedUrl->getRawQuery(), $existingParams); + if ($rawQuery !== null) { + parse_str($rawQuery, $existingParams); } - // New ones take precedence + // Existing parameters take precedence over newly added parameters. $mergedParams = array_merge($queryParams, $existingParams); $query = http_build_query($mergedParams); - $scheme = $parsedUrl->getRawScheme() !== null ? $parsedUrl->getRawScheme() . '://' : ''; - $host = $parsedUrl->getRawHost() ?? ''; - $port = $parsedUrl->getPort() !== null ? ':' . $parsedUrl->getPort() : ''; - $path = $parsedUrl->getRawPath(); return $scheme . $host . $port . $path . '?' . $query; } diff --git a/tests/PaginatorTest.php b/tests/PaginatorTest.php index 90fb5cfa..2ab00799 100644 --- a/tests/PaginatorTest.php +++ b/tests/PaginatorTest.php @@ -126,13 +126,41 @@ public function testAppendQueryParametersPreservesExistingUriComponents(): void $this->assertSame('https://example.com/items?page=3&filter=old&sort=asc', $result); } + public function testAppendQueryParametersMergesWhenQueryContainsBrackets(): void + { + $method = new \ReflectionMethod($this->paginator, 'appendQueryParameters'); + + $this->assertSame( + 'http://x.test/items?page=2&sort=id&filter%5Bstatus%5D=a', + $method->invoke( + $this->paginator, + 'http://x.test/items?filter[status]=a&page=2', + ['page' => 3, 'sort' => 'id'] + ) + ); + } + + public function testAppendQueryParametersMergesWhenQueryContainsSpaces(): void + { + $method = new \ReflectionMethod($this->paginator, 'appendQueryParameters'); + + $this->assertSame( + 'http://x.test/items?page=2&sort=id&q=a+b', + $method->invoke( + $this->paginator, + 'http://x.test/items?q=a b&page=2', + ['page' => 3, 'sort' => 'id'] + ) + ); + } + public function testAppendQueryParametersFallsBackForInvalidUri(): void { $method = new \ReflectionMethod($this->paginator, 'appendQueryParameters'); - $result = $method->invoke($this->paginator, 'http://[invalid', ['page' => 2]); + $result = $method->invoke($this->paginator, 'http://[invalid]:99999', ['page' => 2]); - $this->assertSame('http://[invalid?page=2', $result); + $this->assertSame('http://[invalid]:99999?page=2', $result); } public function testJumpMethod() diff --git a/tests/RedirectResponseTest.php b/tests/RedirectResponseTest.php index 96bebbd0..64a30ba5 100644 --- a/tests/RedirectResponseTest.php +++ b/tests/RedirectResponseTest.php @@ -280,9 +280,20 @@ public function testToMethodWithSecureTruePreservesEncodedComponents(): void ); } + public function testSecureTrueStillUpgradesUrlsWithBracketsAndSpaces(): void + { + $this->redirect->to('http://example.com/a?x[]=1', 302, [], true); + + $this->assertSame('https://example.com/a?x[]=1', $this->redirect->headers->get('Location')); + + $this->redirect->to('http://example.com/a b', 302, [], true); + + $this->assertSame('https://example.com/a b', $this->redirect->headers->get('Location')); + } + public function testToMethodPreservesInvalidAbsoluteUrlWhenForcingScheme(): void { - $url = 'http://[invalid'; + $url = 'http://[invalid]:99999'; $this->redirect->to($url, 302, [], true); diff --git a/tests/Requests/RequestTest.php b/tests/Requests/RequestTest.php index b390b38e..93f1f5ce 100644 --- a/tests/Requests/RequestTest.php +++ b/tests/Requests/RequestTest.php @@ -8,6 +8,7 @@ use Phaseolies\Http\HeaderBag; use Phaseolies\Http\ServerBag; use Phaseolies\Http\InputBag; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class RequestTest extends TestCase @@ -195,35 +196,51 @@ public function testGetPath() $this->assertEquals('/test', $this->request->getPath()); } - public function testGetPathPreservesPathAndIgnoresQuery(): void + #[DataProvider('lenientRequestTargets')] + public function testGetPathToleratesLenientRequestTargets(string $uri, string $expected): void { - $this->request->server->set('REQUEST_URI', '/products/item%20one?filter=active'); + $this->request->server->set('REQUEST_URI', $uri); - $this->assertSame('/products/item one', $this->request->getPath()); + $this->assertSame($expected, $this->request->getPath()); + } + + public static function lenientRequestTargets(): array + { + return [ + 'bracket array query' => ['/users?a[]=1&b=2', '/users'], + 'bracket nested query' => ['/users?filter[status]=active', '/users'], + 'raw space in query' => ['/search?q=hello world', '/search'], + 'raw utf-8 path' => ['/café?x=1', '/café'], + 'braces in query' => ['/users?x={1}', '/users'], + 'pipe in path' => ['/a|b', '/a|b'], + 'authority form' => ['//evil.com/x', '/x'], + 'query only' => ['?a=1', '/'], + 'malformed request target' => ['http://[invalid]:99999', '/'], + ]; } public function testPrepareRequestUriExtractsProxyPathAndQuery(): void { $request = new Request([], [], [], [], [], [ 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'https://proxy.example/products/item%2Fone?filter=active#section', + 'REQUEST_URI' => 'https://proxy.example/products/item%2Fone?filter[status]=active#section', ]); $property = new \ReflectionProperty(Request::class, 'requestUri'); $property->setValue($request, null); - $this->assertSame('/products/item%2Fone?filter=active', $request->getRequestUri()); + $this->assertSame('/products/item%2Fone?filter[status]=active', $request->getRequestUri()); } public function testPrepareRequestUriRetainsInvalidProxyUri(): void { $request = new Request([], [], [], [], [], [ 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'not a valid uri', + 'REQUEST_URI' => 'http://[invalid]:99999', ]); $property = new \ReflectionProperty(Request::class, 'requestUri'); $property->setValue($request, null); - $this->assertSame('not a valid uri', $request->getRequestUri()); + $this->assertSame('http://[invalid]:99999', $request->getRequestUri()); } public function testGetMethod() diff --git a/tests/Router/RouterTest.php b/tests/Router/RouterTest.php index e266d933..9db03af2 100644 --- a/tests/Router/RouterTest.php +++ b/tests/Router/RouterTest.php @@ -149,6 +149,22 @@ protected function tearDown(): void // HTTP Method Registration Tests // ========================================================================= + public function testRoutesLenientRequestTargetWithBracketQueryToItsPath(): void + { + $this->router->get('/', fn() => 'home'); + $this->router->get('/users', fn() => 'users'); + $request = new Request([], [], [], [], [], [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/users?filter[status]=x', + 'HTTP_HOST' => 'example.com', + ]); + + $callback = $this->router->getCallback($request); + + $this->assertIsCallable($callback); + $this->assertSame('users', $callback($request)); + } + public function testGetMethodRegistersRoute(): void { $callback = fn() => 'test response'; diff --git a/tests/UriMigrationTest.php b/tests/UriMigrationTest.php new file mode 100644 index 00000000..cf190a77 --- /dev/null +++ b/tests/UriMigrationTest.php @@ -0,0 +1,41 @@ +isFile() || $file->getExtension() !== 'php') { + continue; + } + + $contents = file_get_contents($file->getPathname()); + preg_match_all('/\bparse_url\s*\(/', $contents, $matches); + + if ($matches[0] !== []) { + $relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($sourceDirectory) + 1)); + $parseUrlCalls[$relativePath] = count($matches[0]); + } + } + + ksort($parseUrlCalls); + + $this->assertSame([ + 'Phaseolies/Http/Request.php' => 2, + 'Phaseolies/Http/Response/RedirectResponse.php' => 1, + 'Phaseolies/Utilities/Paginator.php' => 1, + ], $parseUrlCalls); + } +} From 212d2df23a897e8ca7ac58fb06381cac65ddadc1 Mon Sep 17 00:00:00 2001 From: Pierre SOUVIGNET Date: Fri, 25 Sep 2026 11:22:07 +0200 Subject: [PATCH 3/3] adding proxy tests --- tests/Requests/RequestTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Requests/RequestTest.php b/tests/Requests/RequestTest.php index 93f1f5ce..90fdf4b1 100644 --- a/tests/Requests/RequestTest.php +++ b/tests/Requests/RequestTest.php @@ -231,6 +231,18 @@ public function testPrepareRequestUriExtractsProxyPathAndQuery(): void $this->assertSame('/products/item%2Fone?filter[status]=active', $request->getRequestUri()); } + public function testPrepareRequestUriExtractsProxyPathAndRawSpaceQuery(): void + { + $request = new Request([], [], [], [], [], [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'http://proxy.example/search?q=hello world', + ]); + $property = new \ReflectionProperty(Request::class, 'requestUri'); + $property->setValue($request, null); + + $this->assertSame('/search?q=hello world', $request->getRequestUri()); + } + public function testPrepareRequestUriRetainsInvalidProxyUri(): void { $request = new Request([], [], [], [], [], [