From a332550ee8b87de26df962c53b8f4e8a4dcb116d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:01:15 +0000 Subject: [PATCH 01/11] docs: plan Swoole regression coverage Document the upstream Swoole defects that have direct, reliable Hypervel regressions. Keep unsupported Table atomic operations and Unix datagram behavior out of this change until Hypervel uses those APIs. --- ...-13-swoole-upstream-regression-coverage.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/plans/2026-10-13-swoole-upstream-regression-coverage.md diff --git a/docs/plans/2026-10-13-swoole-upstream-regression-coverage.md b/docs/plans/2026-10-13-swoole-upstream-regression-coverage.md new file mode 100644 index 0000000000..71941b51c0 --- /dev/null +++ b/docs/plans/2026-10-13-swoole-upstream-regression-coverage.md @@ -0,0 +1,25 @@ +# Swoole Upstream Regression Coverage + +## Goal + +Keep deterministic Hypervel regressions for upstream Swoole defects that affect real framework and application paths. Tests remain skipped only while Hypervel's minimum Swoole release lacks the corresponding fix. + +## Test Scope + +- Cover hooked stream shutdown and immediate listener release (#6109 and #6274). +- Cover HTTP client reuse, rejected deferred settings, and closing an unconnected client (#6149, #6173/#6267, and #6186). +- Cover masked/compressed WebSocket frames, fresh masking keys, and invalid compressed control frames (#6158, #6165, and #6209). +- Cover coroutine socket timeout and linger option handling (#6166). +- Cover incremental and multipart request parsing, upload-marker safety, and QUERY requests (#6154, #6161, #6162, #6224, and #6256). +- Cover chunked request boundaries, `100 Continue`, and pipelined response ordering through the real Hypervel HTTP server (#6205 and #6220). +- Cover cancellation during directory iteration and `Coroutine::setTimeLimit()` timer ownership (#6185 and #6270). +- Cover client-certificate enforcement and SNI/default-certificate initialization through TLS integration tests (#6188 and #6214). + +## Verification + +Run each new regression without its version guard against the current Swoole extension and confirm it fails for the expected defect. Then add the narrow Swoole-version skip, including the upstream pull-request URL, and rerun the affected test class. Run the existing package-level suites for every changed area. + +## Deferred Coverage + +- #6126 (`Swoole\Table` atomic row operations) is not included for now because Hypervel does not currently use those APIs. Revisit it if the framework adopts them. +- #6174 (Unix datagram address ownership) is not included for now because Hypervel does not currently expose or use Unix datagram sockets. Revisit it if that changes. From d5d17f6628025b935d110fa74381c651b4cf5346 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:01:15 +0000 Subject: [PATCH 02/11] test: cover Swoole engine regressions Add focused coverage for hooked stream shutdown and listener release, coroutine cancellation and timer ownership, socket option validation, and WebSocket frame handling. Gate only the cases that still fail on the supported Swoole runtime, with each skip pointing to the upstream fix. --- tests/Engine/CoroutineTest.php | 40 +++++++++++ .../Fixtures/cancel-directory-iterator.php | 28 ++++++++ tests/Engine/HookedStreamTest.php | 66 +++++++++++++++++++ tests/Engine/SocketTest.php | 28 ++++++++ tests/Engine/WebSocketTest.php | 57 ++++++++++++++++ 5 files changed, 219 insertions(+) create mode 100644 tests/Engine/Fixtures/cancel-directory-iterator.php create mode 100644 tests/Engine/HookedStreamTest.php diff --git a/tests/Engine/CoroutineTest.php b/tests/Engine/CoroutineTest.php index b62953da7b..9639330622 100644 --- a/tests/Engine/CoroutineTest.php +++ b/tests/Engine/CoroutineTest.php @@ -11,7 +11,10 @@ use Hypervel\Engine\Exceptions\CoroutineDestroyedException; use Hypervel\Engine\Exceptions\RuntimeException; use Hypervel\Tests\TestCase; +use Swoole\Coroutine as SwooleCoroutine; use Swoole\Coroutine\CanceledException; +use Swoole\Timer; +use Symfony\Component\Process\Process; use Throwable; class CoroutineTest extends TestCase @@ -244,6 +247,43 @@ public function testCoroutineJoinReturnsFalseWhenItTimesOut(): void $this->assertFalse(Coroutine::exists($coroutine->getId())); } + public function testCancellationEscapesDirectoryIteration(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires correct coroutine exception delivery from https://github.com/swoole/swoole-src/pull/6185.'); + } + + $process = new Process([ + PHP_BINARY, + __DIR__ . '/Fixtures/cancel-directory-iterator.php', + dirname(__DIR__, 2) . '/vendor/autoload.php', + ]); + $process->setTimeout(2); + $process->mustRun(); + + $this->assertSame("canceled\n", $process->getOutput()); + } + + public function testCoroutineTimeLimitOwnsOneFractionalTimer(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires fractional and owned coroutine time-limit timers from https://github.com/swoole/swoole-src/pull/6270.'); + } + + $timerCount = Timer::stats()['num']; + $counts = []; + + SwooleCoroutine::create(function () use (&$counts): void { + $this->assertTrue(SwooleCoroutine::setTimeLimit(0.05)); + $counts[] = Timer::stats()['num']; + $this->assertTrue(SwooleCoroutine::setTimeLimit(0.1)); + $counts[] = Timer::stats()['num']; + }); + + $this->assertSame([$timerCount + 1, $timerCount + 1], $counts); + $this->assertSame($timerCount, Timer::stats()['num']); + } + public function testCoroutineList(): void { $list = Coroutine::list(); diff --git a/tests/Engine/Fixtures/cancel-directory-iterator.php b/tests/Engine/Fixtures/cancel-directory-iterator.php new file mode 100644 index 0000000000..0a95be47b2 --- /dev/null +++ b/tests/Engine/Fixtures/cancel-directory-iterator.php @@ -0,0 +1,28 @@ +markTestSkipped('Requires hooked stream shutdown result parity from https://github.com/swoole/swoole-src/pull/6109.'); + } + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $this->assertIsResource($server); + $address = stream_socket_get_name($server, false); + $this->assertIsString($address); + $sender = stream_socket_client('tcp://' . $address); + $receiver = stream_socket_accept($server, 1); + + try { + $this->assertIsResource($sender); + $this->assertIsResource($receiver); + $this->assertSame(7, fwrite($sender, 'payload')); + $this->assertTrue(stream_socket_shutdown($sender, STREAM_SHUT_WR)); + $this->assertSame('payload', stream_get_contents($receiver)); + $this->assertTrue(feof($receiver)); + $this->assertFalse(@stream_socket_shutdown($sender, STREAM_SHUT_WR)); + } finally { + foreach ([$sender, $receiver, $server] as $stream) { + if (is_resource($stream)) { + fclose($stream); + } + } + } + } + + public function testClosingAListenerImmediatelyReleasesItsAddress(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires immediate hooked listener release from https://github.com/swoole/swoole-src/pull/6274.'); + } + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $this->assertIsResource($server); + $address = stream_socket_get_name($server, false); + $this->assertIsString($address); + + fclose($server); + + $this->assertFalse(@stream_socket_client('tcp://' . $address)); + + $replacement = stream_socket_server('tcp://' . $address); + + try { + $this->assertIsResource($replacement); + } finally { + if (is_resource($replacement)) { + fclose($replacement); + } + } + } +} diff --git a/tests/Engine/SocketTest.php b/tests/Engine/SocketTest.php index c1b5a01e17..18c16ecceb 100644 --- a/tests/Engine/SocketTest.php +++ b/tests/Engine/SocketTest.php @@ -11,6 +11,7 @@ use Hypervel\Engine\SafeSocket; use Hypervel\Engine\Socket; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use ReflectionProperty; use RuntimeException; use Swoole\Coroutine as SwooleCoroutine; @@ -18,6 +19,7 @@ use Swoole\Coroutine\Socket as SwooleSocket; use Swoole\Coroutine\WaitGroup; use Throwable; +use ValueError; use function Hypervel\Coroutine\go; @@ -236,6 +238,32 @@ function (int $port): void { ); } + #[RunInSeparateProcess] + public function testSocketOptionsAcceptObjectsAndReportTheInvalidLingerField(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires safe coroutine socket option validation from https://github.com/swoole/swoole-src/pull/6166.'); + } + + $socket = new Socket(AF_INET, SOCK_STREAM, SOL_TCP); + + try { + $timeout = (object) ['sec' => 1, 'usec' => 0]; + + $this->assertTrue($socket->setOption(SOL_SOCKET, SO_RCVTIMEO, $timeout)); + $this->assertTrue($socket->setOption(SOL_SOCKET, SO_SNDTIMEO, $timeout)); + + try { + $socket->setOption(SOL_SOCKET, SO_LINGER, ['l_onoff' => 1, 'l_linger' => 65536]); + $this->fail('Expected an invalid linger value to be rejected.'); + } catch (ValueError $exception) { + $this->assertStringContainsString('"l_linger"', $exception->getMessage()); + } + } finally { + $socket->close(); + } + } + public function testServerHandlerExceptionSurfacesInTheParent(): void { $server = new Server('127.0.0.1', 0); diff --git a/tests/Engine/WebSocketTest.php b/tests/Engine/WebSocketTest.php index 0267ba087b..91cd5671e9 100644 --- a/tests/Engine/WebSocketTest.php +++ b/tests/Engine/WebSocketTest.php @@ -65,6 +65,63 @@ public function testFrameModelsTheNativeMaskFlag(): void $this->assertTrue(Frame::from($swooleFrame)->getMask()); } + public function testNativeFramesDecodeMaskedCompressedPayloadsWithoutMutatingTheInput(): void + { + // https://github.com/swoole/swoole-src/pull/6158 + if (! defined('SWOOLE_HAVE_ZLIB')) { + $this->markTestSkipped('Swoole was not compiled with zlib support.'); + } + + $payload = str_repeat('hypervel-websocket-frame:', 20); + $packed = SwooleFrame::pack( + $payload, + Opcode::TEXT, + SWOOLE_WEBSOCKET_FLAG_FIN | SWOOLE_WEBSOCKET_FLAG_MASK | SWOOLE_WEBSOCKET_FLAG_COMPRESS, + ); + $original = $packed; + + $frame = SwooleFrame::unpack($packed); + + $this->assertInstanceOf(SwooleFrame::class, $frame); + $this->assertSame($payload, $frame->data); + $this->assertSame($original, $packed); + } + + public function testMaskedFramesUseFreshKeys(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires fresh WebSocket masking keys from https://github.com/swoole/swoole-src/pull/6165.'); + } + + $keys = []; + + for ($attempt = 0; $attempt < 8; ++$attempt) { + $packed = (string) new Frame(mask: true, payloadData: 'hello'); + $keys[] = substr($packed, 2, 4); + + $this->assertSame('hello', SwooleFrame::unpack($packed)->data); + } + + $this->assertGreaterThan(1, count(array_unique($keys))); + } + + public function testCompressedControlFramesAreRejected(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires compressed control-frame rejection from https://github.com/swoole/swoole-src/pull/6209.'); + } + + foreach ([Opcode::CLOSE, Opcode::PING, Opcode::PONG] as $opcode) { + $packed = SwooleFrame::pack( + '', + $opcode, + SWOOLE_WEBSOCKET_FLAG_FIN | SWOOLE_WEBSOCKET_FLAG_RSV1, + ); + + $this->assertFalse(@SwooleFrame::unpack($packed)); + } + } + public function testResponseReturnsTheNativeResponsePushResult(): void { $connection = new EngineTestSwooleResponse; From e81f58d90d8f51709237c15df303a8d07a9fb63b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:01:15 +0000 Subject: [PATCH 03/11] test: cover native HTTP request parsing regressions Exercise multipart headers split across parse calls, untrusted upload markers, request fields reconstructed from incremental input, and QUERY requests with bodies. The tests use literal local request data so parser behavior is isolated from the network server. --- tests/HttpServer/NativeRequestParsingTest.php | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/HttpServer/NativeRequestParsingTest.php diff --git a/tests/HttpServer/NativeRequestParsingTest.php b/tests/HttpServer/NativeRequestParsingTest.php new file mode 100644 index 0000000000..492fbbe315 --- /dev/null +++ b/tests/HttpServer/NativeRequestParsingTest.php @@ -0,0 +1,164 @@ +temporaryDirectory = ParallelTesting::tempDir('NativeRequestParsingTest'); + $filesystem = new Filesystem; + $filesystem->deleteDirectory($this->temporaryDirectory); + $filesystem->ensureDirectoryExists($this->temporaryDirectory); + } + + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->temporaryDirectory); + + parent::tearDown(); + } + + public function testMultipartHeadersCanBeSplitAcrossParseCalls(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6154'); + } + + $boundary = '------------------------hypervel-split'; + $body = implode("\r\n", [ + '--' . $boundary, + 'Content-Disposition: form-data; name="upload"; filename="report.txt"', + 'Content-Type: text/plain', + '', + 'uploaded contents', + '--' . $boundary . '--', + '', + ]); + $requestData = $this->requestData('POST', '/upload', [ + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, + 'Content-Length' => (string) strlen($body), + ], $body); + $split = strpos($requestData, 'filename="report.txt"') + strlen('filename="repo'); + $request = Request::create(); + + $this->assertSame($split, $request->parse(substr($requestData, 0, $split))); + $this->assertFalse($request->isCompleted()); + $this->assertSame(strlen($requestData) - $split, $request->parse(substr($requestData, $split))); + $this->assertTrue($request->isCompleted()); + $this->assertSame('report.txt', $request->files['upload']['name']); + $this->assertSame('uploaded contents', file_get_contents($request->files['upload']['tmp_name'])); + } + + public function testClientUploadMarkersAreNotTrusted(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6162'); + } + + $probe = $this->temporaryDirectory . '/probe.txt'; + file_put_contents($probe, 'probe'); + $boundary = '------------------------hypervel-marker'; + $body = implode("\r\n", [ + '--' . $boundary, + 'Content-Disposition: form-data; name="upload"; filename="report.txt"', + 'Swoole-Upload-File: ' . $probe, + 'Content-Type: text/plain', + '', + 'uploaded contents', + '--' . $boundary . '--', + '', + ]); + $requestData = $this->requestData('POST', '/upload', [ + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, + 'Content-Length' => (string) strlen($body), + ], $body); + $request = Request::create(); + + $this->assertSame(strlen($requestData), $request->parse($requestData)); + $this->assertTrue($request->isCompleted()); + $this->assertNotSame($probe, $request->files['upload']['tmp_name']); + $this->assertSame('uploaded contents', file_get_contents($request->files['upload']['tmp_name'])); + $this->assertFalse(is_uploaded_file($probe)); + $this->assertFileExists($probe); + } + + public function testRequestFieldsSurviveIncrementalParseCalls(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6224'); + } + + $body = 'first=one&second=two'; + $requestData = $this->requestData('POST', '/search?term=hypervel', [ + 'X-Request-Header' => 'header-value', + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Content-Length' => (string) strlen($body), + ], $body); + $splits = [ + strpos($requestData, 'hypervel') + 3, + strpos($requestData, 'X-Request-Header') + 4, + strpos($requestData, 'header-value') + 5, + strpos($requestData, $body) + 5, + ]; + + foreach ($splits as $split) { + $request = Request::create(); + + $this->assertSame($split, $request->parse(substr($requestData, 0, $split))); + $this->assertSame(strlen($requestData) - $split, $request->parse(substr($requestData, $split))); + $this->assertTrue($request->isCompleted()); + $this->assertSame('/search', $request->server['request_uri']); + $this->assertSame('hypervel', $request->get['term']); + $this->assertSame('header-value', $request->header['x-request-header']); + $this->assertSame(['first' => 'one', 'second' => 'two'], $request->post); + $this->assertSame($requestData, $request->getData()); + } + } + + public function testQueryRequestsWithBodiesAreParsed(): void + { + // Covered upstream by https://github.com/swoole/swoole-src/pull/6256. + $body = 'search terms'; + $requestData = $this->requestData('QUERY', '/search', [ + 'Content-Type' => 'text/plain', + 'Content-Length' => (string) strlen($body), + ], $body); + $request = Request::create(); + + $this->assertSame(strlen($requestData), $request->parse($requestData)); + $this->assertTrue($request->isCompleted()); + $this->assertSame('QUERY', $request->getMethod()); + $this->assertSame($body, $request->rawContent()); + } + + /** + * Build one literal HTTP request for the native incremental parser. + * + * @param array $headers + */ + protected function requestData(string $method, string $target, array $headers, string $body): string + { + $lines = [ + "{$method} {$target} HTTP/1.1", + 'Host: 127.0.0.1', + ]; + + foreach ($headers as $name => $value) { + $lines[] = "{$name}: {$value}"; + } + + return implode("\r\n", [...$lines, '', $body]); + } +} From fb35fe3e5191dfddd4bd4121cd5ff7b24c574fdd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:01:15 +0000 Subject: [PATCH 04/11] test: cover Swoole HTTP client regressions Verify connection-close state does not leak into a later request, invalid deferred proxy settings stop before connecting, and closing an unconnected client reports the expected error. Extend the existing integration fixture with the two small response endpoints needed by the reuse regression. --- tests/Integration/Engine/ClientTest.php | 65 +++++++++++++++++++ .../Engine/Fixtures/http_server.php | 7 ++ 2 files changed, 72 insertions(+) diff --git a/tests/Integration/Engine/ClientTest.php b/tests/Integration/Engine/ClientTest.php index 466c91c1ce..9c6e1dcf84 100644 --- a/tests/Integration/Engine/ClientTest.php +++ b/tests/Integration/Engine/ClientTest.php @@ -134,4 +134,69 @@ public function testClientNotFound(): void $response = $client->request('GET', '/not_found'); $this->assertSame(404, $response->statusCode); } + + public function testClientDoesNotCarryConnectionCloseIntoTheNextRequest(): void + { + // https://github.com/swoole/swoole-src/pull/6149 + $client = new Client($this->getServerHost(), $this->getServerPort()); + + try { + $this->assertSame('closed', $client->request('GET', '/response-close')->body); + $this->assertFalse($client->connected); + $this->assertSame('next', $client->request('GET', '/response-next')->body); + $this->assertTrue($client->connected); + } finally { + $client->close(); + } + } + + public function testClientRejectsInvalidDeferredSocketSettings(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires rejected deferred client settings from https://github.com/swoole/swoole-src/pull/6267.'); + } + + $client = new Client($this->getServerHost(), $this->getServerPort()); + $warnings = []; + + set_error_handler(static function (int $severity, string $message) use (&$warnings): bool { + $warnings[] = $message; + + return true; + }); + + try { + $this->assertTrue($client->set([ + 'socks5_host' => '127.0.0.1', + 'timeout' => 0.2, + ])); + + try { + $client->request('GET', '/'); + $this->fail('Invalid deferred socket settings must stop the request.'); + } catch (HttpClientException $exception) { + $this->assertSame(SWOOLE_ERROR_INVALID_PARAMS, $exception->getCode()); + } + + $this->assertTrue((bool) array_filter( + $warnings, + static fn (string $warning): bool => str_contains($warning, 'socks5_port should not be null'), + )); + } finally { + restore_error_handler(); + $client->close(); + } + } + + public function testClosingAnUnconnectedClientReportsNoConnection(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires initialized coroutine HTTP client socket state from https://github.com/swoole/swoole-src/pull/6186.'); + } + + $client = new Client($this->getServerHost(), $this->getServerPort()); + + $this->assertFalse($client->close()); + $this->assertSame(SWOOLE_ERROR_CLIENT_NO_CONNECTION, $client->errCode); + } } diff --git a/tests/Integration/Engine/Fixtures/http_server.php b/tests/Integration/Engine/Fixtures/http_server.php index cf4f0f2f4b..6f6b54ee18 100644 --- a/tests/Integration/Engine/Fixtures/http_server.php +++ b/tests/Integration/Engine/Fixtures/http_server.php @@ -43,6 +43,13 @@ sleep((int) $time); $response->end($request->get['body'] ?? null); break; + case '/response-close': + $response->header('Connection', 'close'); + $response->end('closed'); + break; + case '/response-next': + $response->end('next'); + break; default: $response->setStatusCode(404); $response->end(); From fb33aec0b375e68a06894bc222242badc5430edd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:01:15 +0000 Subject: [PATCH 05/11] test: cover Hypervel HTTP server regressions Exercise large multipart upload-marker rejection through Hypervel's request conversion, exact chunked request boundaries with 100 Continue, and HTTP/1 pipelined response ordering. Use the shared integration server and bounded local socket reads so the coverage stays deterministic. --- .../HttpServer/Fixtures/server.php | 9 +++ .../HttpServer/RequestConversionTest.php | 35 ++++++++ .../HttpServer/RequestFramingTest.php | 79 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 tests/Integration/HttpServer/RequestFramingTest.php diff --git a/tests/Integration/HttpServer/Fixtures/server.php b/tests/Integration/HttpServer/Fixtures/server.php index 4202cafb07..745b120799 100644 --- a/tests/Integration/HttpServer/Fixtures/server.php +++ b/tests/Integration/HttpServer/Fixtures/server.php @@ -58,6 +58,8 @@ $config->set('server.settings.' . Constant::OPTION_OPEN_HTTP2_PROTOCOL, true); $config->set('server.settings.' . Constant::OPTION_ENABLE_STATIC_HANDLER, false); $config->set('server.settings.' . Constant::OPTION_HTTP_COMPRESSION, false); +$config->set('server.settings.' . Constant::OPTION_PACKAGE_MAX_LENGTH, 64 * 1024); +$config->set('server.settings.' . Constant::OPTION_UPLOAD_MAX_FILESIZE, 1024 * 1024); $app->make(KernelContract::class)->pushMiddleware(HttpServerIntegrationMiddleware::class); @@ -144,6 +146,7 @@ 'query' => $request->query->all(), 'request' => $request->request->all(), 'json' => $request->json()->all(), + 'content' => $request->getContent(), 'header' => $request->header('X-Integration'), 'file' => $file instanceof UploadedFile ? [ 'name' => $file->getClientOriginalName(), @@ -158,6 +161,12 @@ return (string) $request->query('value'); }); +$router->get('/pipeline/first', function (): string { + usleep(20_000); + + return 'first response'; +}); +$router->get('/pipeline/second', fn (): string => 'second response'); $router->get('/cookies', function (): Response { $response = new Response('cookies'); $response->headers->setCookie( diff --git a/tests/Integration/HttpServer/RequestConversionTest.php b/tests/Integration/HttpServer/RequestConversionTest.php index f609ddb63a..fb736c5e75 100644 --- a/tests/Integration/HttpServer/RequestConversionTest.php +++ b/tests/Integration/HttpServer/RequestConversionTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Integration\HttpServer; use Hypervel\Engine\Http\Client as EngineClient; +use Hypervel\Filesystem\Filesystem; +use Hypervel\Testing\ParallelTesting; use function Hypervel\Coroutine\parallel; @@ -73,6 +75,39 @@ public function testMultipartFieldsAndFilesAreConverted(): void $this->assertSame('uploaded contents', $payload['file']['contents']); } + public function testLargeUploadsRejectClientProvidedInternalMarkers(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6161'); + } + + $directory = ParallelTesting::tempDir('large-upload-marker'); + $filesystem = new Filesystem; + $filesystem->ensureDirectoryExists($directory); + $probe = $directory . '/probe.txt'; + $filesystem->put($probe, 'probe'); + + try { + $response = $this->request('POST', '/inspect', [ + 'multipart' => [[ + 'name' => 'upload', + 'contents' => str_repeat('x', 80 * 1024), + 'filename' => 'large.txt', + 'headers' => [ + 'Content-Type' => 'text/plain', + 'Swoole-Upload-File' => $probe, + ], + ]], + ]); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertFileExists($probe); + $this->assertSame('probe', $filesystem->get($probe)); + } finally { + $filesystem->deleteDirectory($directory); + } + } + public function testConcurrentRequestsKeepTheirOwnRequestContext(): void { $host = $this->getServerHost(); diff --git a/tests/Integration/HttpServer/RequestFramingTest.php b/tests/Integration/HttpServer/RequestFramingTest.php new file mode 100644 index 0000000000..7578eb81ce --- /dev/null +++ b/tests/Integration/HttpServer/RequestFramingTest.php @@ -0,0 +1,79 @@ +markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6205'); + } + + $response = $this->sendRawRequest( + "POST /inspect HTTP/1.1\r\n" + . "Host: 127.0.0.1\r\n" + . "Transfer-Encoding: chunked\r\n" + . "Expect: 100-continue\r\n\r\n" + . "5;name=value\r\nhello\r\n" + . "0\r\nX-Trailer: value\r\n\r\n" + . "GET /up HTTP/1.1\r\n" + . "Host: 127.0.0.1\r\n" + . "Connection: close\r\n\r\n" + ); + + $this->assertSame(1, substr_count($response, 'HTTP/1.1 100 Continue')); + $this->assertSame(2, substr_count($response, 'HTTP/1.1 200 OK')); + $this->assertStringContainsString('"content":"hello"', $response); + $this->assertStringEndsWith('up', $response); + } + + public function testPipelinedResponsesStayInRequestOrder(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6220'); + } + + $response = $this->sendRawRequest( + "GET /pipeline/first HTTP/1.1\r\n" + . "Host: 127.0.0.1\r\n\r\n" + . "GET /pipeline/second HTTP/1.1\r\n" + . "Host: 127.0.0.1\r\n" + . "Connection: close\r\n\r\n" + ); + $first = strpos($response, 'first response'); + $second = strpos($response, 'second response'); + + $this->assertNotFalse($first); + $this->assertNotFalse($second); + $this->assertLessThan($second, $first); + } + + protected function sendRawRequest(string $request): string + { + $socket = stream_socket_client( + sprintf('tcp://%s:%d', $this->getServerHost(), $this->getServerPort()), + $errorCode, + $errorMessage, + 2 + ); + + if (! is_resource($socket)) { + throw new RuntimeException("Unable to connect to the HTTP test server: {$errorMessage}", $errorCode); + } + + stream_set_timeout($socket, 2); + + try { + $this->assertSame(strlen($request), fwrite($socket, $request)); + + return stream_get_contents($socket); + } finally { + fclose($socket); + } + } +} From 6e59f20783a09c38bc06f248350ca9352a36a8d6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:01:15 +0000 Subject: [PATCH 06/11] test: cover Swoole server TLS regressions Add local TLS fixtures and integration servers for mutual TLS and SNI-only configuration. Verify client certificates are required, SNI-only listeners complete handshakes, and malformed default certificates are rejected during configuration. --- bin/test-servers.sh | 12 +++- .../HttpServer/Fixtures/Tls/ca.crt | 19 +++++ .../HttpServer/Fixtures/Tls/client.crt | 19 +++++ .../HttpServer/Fixtures/Tls/client.key | 28 ++++++++ .../HttpServer/Fixtures/Tls/invalid.crt | 1 + .../HttpServer/Fixtures/Tls/server.crt | 20 ++++++ .../HttpServer/Fixtures/Tls/server.key | 28 ++++++++ .../HttpServer/Fixtures/tls_server.php | 54 ++++++++++++++ .../Integration/HttpServer/MutualTlsTest.php | 69 ++++++++++++++++++ tests/Integration/HttpServer/SniTlsTest.php | 71 +++++++++++++++++++ 10 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/HttpServer/Fixtures/Tls/ca.crt create mode 100644 tests/Integration/HttpServer/Fixtures/Tls/client.crt create mode 100644 tests/Integration/HttpServer/Fixtures/Tls/client.key create mode 100644 tests/Integration/HttpServer/Fixtures/Tls/invalid.crt create mode 100644 tests/Integration/HttpServer/Fixtures/Tls/server.crt create mode 100644 tests/Integration/HttpServer/Fixtures/Tls/server.key create mode 100644 tests/Integration/HttpServer/Fixtures/tls_server.php create mode 100644 tests/Integration/HttpServer/MutualTlsTest.php create mode 100644 tests/Integration/HttpServer/SniTlsTest.php diff --git a/bin/test-servers.sh b/bin/test-servers.sh index 77cfa6831d..20e17057a6 100755 --- a/bin/test-servers.sh +++ b/bin/test-servers.sh @@ -11,7 +11,7 @@ # # Groups: # engine — HTTP (19501), TCP (19502), WebSocket (19503), HTTP v2 (19505), -# Hypervel HTTP Server (19506) +# Hypervel HTTP Server (19506), mTLS (19507), SNI-only TLS (19508) # grpc — Hypervel plaintext (19520), grpc-go plaintext (19521), # Hypervel TLS (19522), grpc-go TLS (19523) # reverb — Single-worker (19510), Redis scaling (19511), multi-worker (19512), @@ -122,11 +122,21 @@ start_engine() { PIDS+=($!) echo " Hypervel HTTP Server starting on port 19506 (PID: $!)..." + setsid php "$PROJECT_DIR/tests/Integration/HttpServer/Fixtures/tls_server.php" mtls 19507 & + PIDS+=($!) + echo " mTLS test server starting on port 19507 (PID: $!)..." + + setsid php "$PROJECT_DIR/tests/Integration/HttpServer/Fixtures/tls_server.php" sni 19508 & + PIDS+=($!) + echo " SNI-only TLS test server starting on port 19508 (PID: $!)..." + wait_for_tcp 19501 "HTTP" wait_for_tcp 19502 "TCP packet" wait_for_tcp 19503 "WebSocket" wait_for_tcp 19505 "HTTP v2" wait_for_server 19506 "Hypervel HTTP Server" + wait_for_tcp 19507 "mTLS" + wait_for_tcp 19508 "SNI-only TLS" } start_grpc() { diff --git a/tests/Integration/HttpServer/Fixtures/Tls/ca.crt b/tests/Integration/HttpServer/Fixtures/Tls/ca.crt new file mode 100644 index 0000000000..1fe129038b --- /dev/null +++ b/tests/Integration/HttpServer/Fixtures/Tls/ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUS2hE1hTY70lYsimltN9XImT76+MwDQYJKoZIhvcNAQEL +BQAwIjEgMB4GA1UEAwwXSHlwZXJ2ZWwgU3dvb2xlIFRlc3QgQ0EwHhcNMjYxMDEz +MDU0MzQ2WhcNMzYxMDEwMDU0MzQ2WjAiMSAwHgYDVQQDDBdIeXBlcnZlbCBTd29v +bGUgVGVzdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALLFSkJM +pyBXa0al8yEk6L6zhwpCaELV43FIvVOozWJ+FUCrp1qld8RIPI01B7/h0f5cBfhP +giulNIwlBpbjB0h2II+9CgxbxJJLiVLoK3kVCTgV2qw59uxs4HwughbHAWJWWkKy +XA01m9B9ki4L4Xpa0Em13UTuzNFd2kZb/Eur3fY5zUU9T3zTjSWHsYwrd1nHRKQr +iVklFKClBbEwS89GeVluWtTLiP0S9Wiox2IZGuq0U84hq/G+I4lYZXPNuRvgT4ME +DToRVmWB/rOv6RDVFohHtnfMJVj9syUcK2Vm2XGun2MFyj9AFiFaLqk/VivdFJpW +gBClFF/gl86auh8CAwEAAaNTMFEwHQYDVR0OBBYEFIVAAKkqXBYEKq/EenOqFdGx +umtqMB8GA1UdIwQYMBaAFIVAAKkqXBYEKq/EenOqFdGxumtqMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHL90e62JVFUh3WgYSadiIdOzUo32D4Z +q9dsl0n8SbEv+c3mw8knihweqMqnuYH4JPKfg4QdYMszSaZUHgQpjFjrQ3dSGWkc +r6W9jBMbRQwYL6yKQnSHcNWHUJf2S8nTLx8gXEIHu+ynRjZakEfVsEC+61HRJncN +MzZQmIJwttgnlwi+SiqAS88q40d/G04SRRNwVNPEoL4rtusdmXPxWo+1SUlEYPNr +XbSUvTBtpc0iv9Js74DD5GCPxbkMzYB38L6RBojQPbQR4KXx7kbM4CLZ4RlXqJh4 +e6yTwFAfhxbZZKQ8/iMFv9wCDWAkiw4dtjfisV6ehibqfh3Lev2MECA= +-----END CERTIFICATE----- diff --git a/tests/Integration/HttpServer/Fixtures/Tls/client.crt b/tests/Integration/HttpServer/Fixtures/Tls/client.crt new file mode 100644 index 0000000000..80bbfff288 --- /dev/null +++ b/tests/Integration/HttpServer/Fixtures/Tls/client.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDITCCAgmgAwIBAgIUQsNsyK1ZmBU+1THskh7FCHFZDmAwDQYJKoZIhvcNAQEL +BQAwIjEgMB4GA1UEAwwXSHlwZXJ2ZWwgU3dvb2xlIFRlc3QgQ0EwHhcNMjYxMDEz +MDU0MzQ2WhcNMzYxMDEwMDU0MzQ2WjAaMRgwFgYDVQQDDA9oeXBlcnZlbC1jbGll +bnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCt92tcltOu/PVOi/Oi +AoF07b0C24tPfkMiSAlVCDFY+wbiJ5QNHbyF+n90YNuN8gF18+3WRjn0BNhCxw4F +auOQvF8+N/8Sdx5NOtVaeog6Gz5x4wXYAFvnDalFue1fS3EBpiwYQHTohQovl+Bo +2I8TuzuZX3fSNTU1PH8RfBbDrh0KcdrqZi2VxmpngAze5vioOoF9XZSpbpOntb+V +LCqm9xOC751H5QAyG5B4x9ZVSVEnyb6fAvafrkgAFqjpH6iopa9B05emgksOHLBc +Ar/YL4hYROiQPb/qof0Ds1E239pseIG3hXC7Ruw5gxTmYrh5vnBFLtLgWdQcKZ6f +63EpAgMBAAGjVzBVMBMGA1UdJQQMMAoGCCsGAQUFBwMCMB0GA1UdDgQWBBQ9rIIU +cD9+vNJg5ZBkFREGWRhgljAfBgNVHSMEGDAWgBSFQACpKlwWBCqvxHpzqhXRsbpr +ajANBgkqhkiG9w0BAQsFAAOCAQEAEkt1M2va+f0cMH6zuE+zkzQB+JADa5HnJEmZ +eHI48vfhh0qYuxrvNMWB9u2ss2dBrCoyqeSFpNj+FNWcZt6K4telfgEVRhWyRJFW +BbfThQx04TOHbli6LY6IJhBJ9AK3E5epOzi6F9C5WWOmhPoOMDxqMJ8lCkRcQdBy +LPFl1WZScO8SANUnBG2HSrMb/yEkkX+9FJ1rxu7HDaPVYWXLWq7Nfd66UPtrp27x +TkAsJ9PeNmPyZ3hhlMnSNWwStNOm9/Z3RZppNhEMnLCf0E0mrWS6uozyVBWQbpnE +8neCEHlaLPFVxbpQfKh37ZWPoxmu3683Hr64eE2qk3V4KIRzyg== +-----END CERTIFICATE----- diff --git a/tests/Integration/HttpServer/Fixtures/Tls/client.key b/tests/Integration/HttpServer/Fixtures/Tls/client.key new file mode 100644 index 0000000000..c6b003511f --- /dev/null +++ b/tests/Integration/HttpServer/Fixtures/Tls/client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCt92tcltOu/PVO +i/OiAoF07b0C24tPfkMiSAlVCDFY+wbiJ5QNHbyF+n90YNuN8gF18+3WRjn0BNhC +xw4FauOQvF8+N/8Sdx5NOtVaeog6Gz5x4wXYAFvnDalFue1fS3EBpiwYQHTohQov +l+Bo2I8TuzuZX3fSNTU1PH8RfBbDrh0KcdrqZi2VxmpngAze5vioOoF9XZSpbpOn +tb+VLCqm9xOC751H5QAyG5B4x9ZVSVEnyb6fAvafrkgAFqjpH6iopa9B05emgksO +HLBcAr/YL4hYROiQPb/qof0Ds1E239pseIG3hXC7Ruw5gxTmYrh5vnBFLtLgWdQc +KZ6f63EpAgMBAAECggEABfYlwzarSrgCGK7ParIG9ACrn/vxsl0uJBrE2xmcm03V +kWLFfjeSfM2LQuveH5H3U9lH7Auxs7eGHlpflCGdam8ncmSNL075W7B+8YqjvXNZ +OKI3fTdHREvht5vPUxss5F9olmzVtMbOWmLW1AZLzON3KjfXzX0joCSSaYEk7zqr +KgZGsgc6Uydnke4rSOWz1VZM0csODVfswY4Bm2xm3pktF5tERc1CVYq/zmYXzhKn +bUAFP4iXixeRSK2OolK9o17HZ1SaSF7bOiJ55uAKwXVhBfdDjbVhAnISWBvGTaG1 +0Jmkko/Ttxn/MdmtMp7fLuwLc57IPbgphm6jCXJcQwKBgQDtw0wQeUKtz9t5A7wb +qR2cUl7kA8Tso+eJgnbFOiXo4rhUGQiKNQ83S3PhOR1Puq3upUwmqvTNMNdCMD+U +cwUOPPgDahFXwR306XVcNe2Na48bHmedWUpD7tMSLqpAzJpJvA2zC613/dSvuKrS +Ri+FG5XOLX8IPVUTxuUxSfocSwKBgQC7T2sqexo5zCRZSlZucDUSp/PgfWQpwKSS +BcYwNE4hqOHwZYyUxB9F6ZcY9Nznlu0O0JkVhmDVQA3KHFhFDaDeFFC9NrEZsaAJ +WowUybODOS7vDtueqFvV06zIWVgMqgcpkd9+REvUKGGxdE058qABopofnQvHAgoP +2Tqby6+X2wKBgCclLlLQBCBlxnaoHMJZF5zzGthjnnpvieFj73YNWsx692oxLOzE +S5sI+y0roB3OWHCgGmeLYf6eefJVy0H/pedwNk0pm21dSDdYcJAb+N59xPPsD5ci +ak3A9lNaB4Hfrc9R/NdrpsXIrsm3l7hDAfPiVMm7R9ztbCJzbqysx1b9AoGBAIlM +qspF2DB7oj1NT1O+rjiRoiNw5oXwbvGWh/L3tDnaGFvlpAm8RQwzMI2bl32u1O+i +GkR77ukB/dmjs1RSrBN9or0/pMuntx6EH+BxBUPwG6YiJvwCfuabNbDaRL9rtrpS +8hcVKtxGCW5RSPZsEbuBHCaGMIbcyNFbMynWQU03AoGAJQkim11FUgvQYPIWrxTx +JJadPLK2vhCCE3qGOy6hWQSkd7k23Y5Fixc5U5naOsljGN3CB7YF48J8dOEHfAgd +SPSNaOWG5BB9oF3az/kULdhRlnrktwxtXe/qiJ+52DrFBA0tW1J6w7q/wz37HZin +Ps4Z0LxcHFIQzatqOIWSujU= +-----END PRIVATE KEY----- diff --git a/tests/Integration/HttpServer/Fixtures/Tls/invalid.crt b/tests/Integration/HttpServer/Fixtures/Tls/invalid.crt new file mode 100644 index 0000000000..2716bf650c --- /dev/null +++ b/tests/Integration/HttpServer/Fixtures/Tls/invalid.crt @@ -0,0 +1 @@ +not a certificate diff --git a/tests/Integration/HttpServer/Fixtures/Tls/server.crt b/tests/Integration/HttpServer/Fixtures/Tls/server.crt new file mode 100644 index 0000000000..c3b59685ef --- /dev/null +++ b/tests/Integration/HttpServer/Fixtures/Tls/server.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDNzCCAh+gAwIBAgIUQsNsyK1ZmBU+1THskh7FCHFZDl8wDQYJKoZIhvcNAQEL +BQAwIjEgMB4GA1UEAwwXSHlwZXJ2ZWwgU3dvb2xlIFRlc3QgQ0EwHhcNMjYxMDEz +MDU0MzQ2WhcNMzYxMDEwMDU0MzQ2WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq+SAwxBcQ08HFN/uSpSbr+Kq9 +okDOYDVKTV999Q3bL42hO+QqmYPyNn20wRgwazfEkt01gNp5TnOrjki+ApU5G9Bb +t/1JypkJ63HnRUuxeLbkJoQCREixdQcJc65CkGGmRi0ru9ploMyQejx2szaJ7jbZ +whgw8dIhEsSSASCLm01M1cxxHZrbIVJGVwtjLBm+2U3G5ojJfy/zcZ6Rj4TdXjII +ReeLg6yGGqQObo+rDXy73BFewjcqNY/Th4uJKUL+r/B/A1d4sX1CzJWI8M91iV1J +I82l7vtMyS9hwOqkPFoVusL2q2jL5r0Drs05CkGE7Jqvk7x2WjuXfshVeY7VAgMB +AAGjczBxMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATATBgNVHSUEDDAKBggr +BgEFBQcDATAdBgNVHQ4EFgQU1oWVXbzgaAmFr8c58Lttz6h3W0YwHwYDVR0jBBgw +FoAUhUAAqSpcFgQqr8R6c6oV0bG6a2owDQYJKoZIhvcNAQELBQADggEBAI1qgVil +Lk6+0r1mhreXMp7pJZhrzZ+6LpQs1nv5w9s5aEJaDz2Nrm7LYwkXM9VzyiQ7+otb +cLFDz5uRZ+IYA/aTp0ZvvWBaLRO8AuvnSYHBZS1T+CFIVgt4xi8+PbAxuh9ZrHgz +8whAf+str44hXKsuztO9sdGWS2EMcO9Lphkuhr/NjhinE+EDTrmDGOVbE36XmGGt +Yp9AdnDSwTJ9P4Y3zqKHwAKuTqodhi7470ZBY3vTW0pIvBuNYjj0NPx+ia6amqWJ +mk5G4djiRGJryVrPCrX5cG3W0cIldUxJ8v6QWjQpoxC9UmBpopQbAlb2VnG+kQy7 +4dr8tgYXCuIlex8= +-----END CERTIFICATE----- diff --git a/tests/Integration/HttpServer/Fixtures/Tls/server.key b/tests/Integration/HttpServer/Fixtures/Tls/server.key new file mode 100644 index 0000000000..730e693ca4 --- /dev/null +++ b/tests/Integration/HttpServer/Fixtures/Tls/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCq+SAwxBcQ08HF +N/uSpSbr+Kq9okDOYDVKTV999Q3bL42hO+QqmYPyNn20wRgwazfEkt01gNp5TnOr +jki+ApU5G9Bbt/1JypkJ63HnRUuxeLbkJoQCREixdQcJc65CkGGmRi0ru9ploMyQ +ejx2szaJ7jbZwhgw8dIhEsSSASCLm01M1cxxHZrbIVJGVwtjLBm+2U3G5ojJfy/z +cZ6Rj4TdXjIIReeLg6yGGqQObo+rDXy73BFewjcqNY/Th4uJKUL+r/B/A1d4sX1C +zJWI8M91iV1JI82l7vtMyS9hwOqkPFoVusL2q2jL5r0Drs05CkGE7Jqvk7x2WjuX +fshVeY7VAgMBAAECggEADTFWA41N/lVosVAlkGY//5tbiXB4sbQIyKf5w11iaVRd +CGD3tTXNUaZUwtrXg/zx+7Adc6Byu0OLd2RuRoMqgpKb3EfxOn1wm6xYSLE3KwWW +jzpNm2MrfM378gXEH4zGjC8EE0jWjFsBDfDws3QWtEP2ZY0tkrFGNpkdI+sEWOFN +m9JyLi3ZAiA6VD6eBzhvAPamtAUA0sEN0RL3P9EjBDE0MwarjIpJihkMPdCvRk9V +/BWeGYPe77HMKnkvg5z+dVC0HS3yih05EYubi2V/e0mVp73zCsWZ0Nb2l2AzDGj9 +1hG7l44QdUOoPJWTAOwGHuY41x0vR7A0zbwevUvtQQKBgQDcfUDtyXD9FBlV1AHP +sxEHi93uD4Uu4TqQuPJacsUrA/WxkUYBdn+UC9nXR3qgnyjAWNjvp4a+/aK8gheW +cSOP6zC/+VXRNa0uvNnAb8yg0uJkLgoKxFyRibFIla9FHdhIPlkM35Uxsy4tc9l2 +V8zH/2Gi2y4NIjh0ljJF6a6uUQKBgQDGglQFjWZKp+/Q4xKmdbbTjvswMrZn10PV +cGvpaj/n4WU9/D7lKQU0jcOKq6u9sHz+oYBDHsHznV/03gzJq2kRks4juHMRuYl8 +gCSxAP330lXtv/Lk0AYbeg5C6eV7YDgd1fxumJj5OAAD1Ja9XbhvgJB/yShcUEKO +XGyHnTejRQKBgQDJOZqfKR/wKH6ywOjiSFc7Xt+tGwb+cNlFKTUtbN8a0eNSKfYM +8bufPm2SNo7otji3yp45odesyMfTaJrkVt8QwqFcIs66P1WNeRb1tFK5V5z7trS9 +fNXKkTJ1vv4VgxZXYGdfA1Ii6Jc1LiyB/8FJqX3O5l+R5vBFHqpE+e7M8QKBgDZT +1MrwN96Wt3tjqecEsFSV/Qjj2CfzMg1oByCKg5LijOK59jyhTVtksO9yrD3t30eQ +y5mTAWG64FgTrUxELS0yEFiWUme3b3kzWNdYfikk13+J2N/mTsWWUfDeMn/hhXok +5kEeYj9SpC5oMBkDXUam2OUgGHurO4van5k93cxBAoGAKFNvxO6pu2TGAaVolNUw +92L7Dnce6rQWQ1sUr/REZNnSe4xcE54ULSkpCW/R42zio0D0MeS8uR5hQNc7ln5z +GzqoL2YWEjjk2nmNYTBmDbVKIPIfidwPHBlemQlfubWrBvKg1WpfrtZhdMygYhf8 +TDYf3IR3GyZYRujG+pg7eCE= +-----END PRIVATE KEY----- diff --git a/tests/Integration/HttpServer/Fixtures/tls_server.php b/tests/Integration/HttpServer/Fixtures/tls_server.php new file mode 100644 index 0000000000..6518a0b8a6 --- /dev/null +++ b/tests/Integration/HttpServer/Fixtures/tls_server.php @@ -0,0 +1,54 @@ + '/dev/null', + 'worker_num' => 1, +]; + +if ($mode === 'mtls') { + $settings += [ + 'ssl_cert_file' => $tlsDirectory . '/server.crt', + 'ssl_key_file' => $tlsDirectory . '/server.key', + 'ssl_verify_peer' => true, + 'ssl_cafile' => $tlsDirectory . '/ca.crt', + ]; +} elseif ($mode === 'sni') { + $settings['ssl_sni_certs'] = [ + 'localhost' => [ + 'ssl_cert_file' => $tlsDirectory . '/server.crt', + 'ssl_key_file' => $tlsDirectory . '/server.key', + ], + ]; +} elseif ($mode === 'invalid') { + $settings += [ + 'ssl_cert_file' => $tlsDirectory . '/invalid.crt', + 'ssl_key_file' => $tlsDirectory . '/server.key', + ]; + + echo @$server->set($settings) ? 'accepted' : 'rejected'; + exit; +} else { + fwrite(STDERR, "Unknown TLS test mode.\n"); + exit(1); +} + +if (! $server->set($settings)) { + exit(1); +} + +$server->on('request', static function (Request $request, Response $response): void { + $response->end('secure'); +}); +$server->start(); diff --git a/tests/Integration/HttpServer/MutualTlsTest.php b/tests/Integration/HttpServer/MutualTlsTest.php new file mode 100644 index 0000000000..4b11a449ad --- /dev/null +++ b/tests/Integration/HttpServer/MutualTlsTest.php @@ -0,0 +1,69 @@ +setUpInteractsWithServer(); + } + + public function testMutualTlsRequiresAClientCertificate(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6188'); + } + + $this->assertFalse($this->connect()); + $this->assertStringContainsString('secure', $this->connect(withClientCertificate: true)); + } + + protected function connect(bool $withClientCertificate = false): string|false + { + $tlsDirectory = __DIR__ . '/Fixtures/Tls'; + $options = [ + 'verify_peer' => true, + 'verify_peer_name' => true, + 'cafile' => $tlsDirectory . '/ca.crt', + 'peer_name' => 'localhost', + ]; + + if ($withClientCertificate) { + $options['local_cert'] = $tlsDirectory . '/client.crt'; + $options['local_pk'] = $tlsDirectory . '/client.key'; + } + + $socket = @stream_socket_client( + sprintf('tls://%s:%d', $this->getServerHost(), $this->getServerPort()), + $errorCode, + $errorMessage, + 2, + STREAM_CLIENT_CONNECT, + stream_context_create(['ssl' => $options]), + ); + + if (! is_resource($socket)) { + return false; + } + + stream_set_timeout($socket, 2); + fwrite($socket, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + $response = stream_get_contents($socket); + fclose($socket); + + return $response; + } +} diff --git a/tests/Integration/HttpServer/SniTlsTest.php b/tests/Integration/HttpServer/SniTlsTest.php new file mode 100644 index 0000000000..eccff2d06d --- /dev/null +++ b/tests/Integration/HttpServer/SniTlsTest.php @@ -0,0 +1,71 @@ +setUpInteractsWithServer(); + } + + public function testSniOnlyServersCompleteTlsHandshakes(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6214'); + } + + $tlsDirectory = __DIR__ . '/Fixtures/Tls'; + $socket = stream_socket_client( + sprintf('tls://%s:%d', $this->getServerHost(), $this->getServerPort()), + $errorCode, + $errorMessage, + 2, + STREAM_CLIENT_CONNECT, + stream_context_create(['ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + 'cafile' => $tlsDirectory . '/ca.crt', + 'peer_name' => 'localhost', + ]]), + ); + + $this->assertIsResource($socket, $errorMessage); + stream_set_timeout($socket, 2); + fwrite($socket, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + $response = stream_get_contents($socket); + fclose($socket); + + $this->assertStringContainsString('secure', $response); + } + + public function testMalformedDefaultCertificatesAreRejected(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6214'); + } + + $process = new Process([ + PHP_BINARY, + __DIR__ . '/Fixtures/tls_server.php', + 'invalid', + '0', + ]); + $process->mustRun(); + + $this->assertSame('rejected', $process->getOutput()); + } +} From c8dc3ccc42fce95494e4a67f3f99315291169775 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:44:43 +0000 Subject: [PATCH 07/11] test: tighten Swoole engine regressions Build compressed control-frame cases from a valid compressed data frame so the regression exercises the invalid wire input directly. Assert the frame bits and preserve an immutable input snapshot. Move the unconnected HTTP client close regression into the unit suite because it does not need the shared integration server. --- tests/Engine/HttpClientTest.php | 23 ++++++++++++++ tests/Engine/WebSocketTest.php | 40 +++++++++++++++++++------ tests/Integration/Engine/ClientTest.php | 12 -------- 3 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 tests/Engine/HttpClientTest.php diff --git a/tests/Engine/HttpClientTest.php b/tests/Engine/HttpClientTest.php new file mode 100644 index 0000000000..824efb7f7f --- /dev/null +++ b/tests/Engine/HttpClientTest.php @@ -0,0 +1,23 @@ +markTestSkipped('Requires initialized coroutine HTTP client socket state from https://github.com/swoole/swoole-src/pull/6186.'); + } + + $client = new Client('127.0.0.1', 80); + + $this->assertFalse($client->close()); + $this->assertSame(SWOOLE_ERROR_CLIENT_NO_CONNECTION, $client->errCode); + } +} diff --git a/tests/Engine/WebSocketTest.php b/tests/Engine/WebSocketTest.php index 91cd5671e9..b36fe48fa9 100644 --- a/tests/Engine/WebSocketTest.php +++ b/tests/Engine/WebSocketTest.php @@ -67,24 +67,30 @@ public function testFrameModelsTheNativeMaskFlag(): void public function testNativeFramesDecodeMaskedCompressedPayloadsWithoutMutatingTheInput(): void { - // https://github.com/swoole/swoole-src/pull/6158 if (! defined('SWOOLE_HAVE_ZLIB')) { $this->markTestSkipped('Swoole was not compiled with zlib support.'); } + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires input-preserving WebSocket decoding from https://github.com/swoole/swoole-src/pull/6158.'); + } + $payload = str_repeat('hypervel-websocket-frame:', 20); $packed = SwooleFrame::pack( $payload, Opcode::TEXT, SWOOLE_WEBSOCKET_FLAG_FIN | SWOOLE_WEBSOCKET_FLAG_MASK | SWOOLE_WEBSOCKET_FLAG_COMPRESS, ); - $original = $packed; + $original = bin2hex($packed); + + $this->assertNotSame(0, ord($packed[0]) & 0x40); + $this->assertNotSame(0, ord($packed[1]) & 0x80); $frame = SwooleFrame::unpack($packed); $this->assertInstanceOf(SwooleFrame::class, $frame); $this->assertSame($payload, $frame->data); - $this->assertSame($original, $packed); + $this->assertSame($original, bin2hex($packed)); } public function testMaskedFramesUseFreshKeys(): void @@ -107,18 +113,34 @@ public function testMaskedFramesUseFreshKeys(): void public function testCompressedControlFramesAreRejected(): void { + if (! defined('SWOOLE_HAVE_ZLIB')) { + $this->markTestSkipped('Swoole was not compiled with zlib support.'); + } + if (SWOOLE_VERSION_ID <= 60203) { $this->markTestSkipped('Requires compressed control-frame rejection from https://github.com/swoole/swoole-src/pull/6209.'); } + $payload = "\x03\xe8bye"; + $packed = SwooleFrame::pack( + $payload, + Opcode::TEXT, + SWOOLE_WEBSOCKET_FLAG_FIN | SWOOLE_WEBSOCKET_FLAG_COMPRESS, + ); + $textFrame = SwooleFrame::unpack($packed); + + $this->assertNotSame(0, ord($packed[0]) & 0x40); + $this->assertSame(Opcode::TEXT, ord($packed[0]) & 0x0F); + $this->assertInstanceOf(SwooleFrame::class, $textFrame); + $this->assertSame($payload, $textFrame->data); + foreach ([Opcode::CLOSE, Opcode::PING, Opcode::PONG] as $opcode) { - $packed = SwooleFrame::pack( - '', - $opcode, - SWOOLE_WEBSOCKET_FLAG_FIN | SWOOLE_WEBSOCKET_FLAG_RSV1, - ); + $invalid = $packed; + $invalid[0] = chr((ord($invalid[0]) & 0xF0) | $opcode); - $this->assertFalse(@SwooleFrame::unpack($packed)); + $this->assertNotSame(0, ord($invalid[0]) & 0x40); + $this->assertSame($opcode, ord($invalid[0]) & 0x0F); + $this->assertFalse(@SwooleFrame::unpack($invalid)); } } diff --git a/tests/Integration/Engine/ClientTest.php b/tests/Integration/Engine/ClientTest.php index 9c6e1dcf84..37fe33e31a 100644 --- a/tests/Integration/Engine/ClientTest.php +++ b/tests/Integration/Engine/ClientTest.php @@ -187,16 +187,4 @@ public function testClientRejectsInvalidDeferredSocketSettings(): void $client->close(); } } - - public function testClosingAnUnconnectedClientReportsNoConnection(): void - { - if (SWOOLE_VERSION_ID <= 60203) { - $this->markTestSkipped('Requires initialized coroutine HTTP client socket state from https://github.com/swoole/swoole-src/pull/6186.'); - } - - $client = new Client($this->getServerHost(), $this->getServerPort()); - - $this->assertFalse($client->close()); - $this->assertSame(SWOOLE_ERROR_CLIENT_NO_CONNECTION, $client->errCode); - } } From 12b8c485ef2352499d4f7a7af13c514b8b41e2f6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:44:55 +0000 Subject: [PATCH 08/11] test: exercise HTTP regressions through the server Send QUERY and Expect requests through the real Hypervel HTTP server so the regressions cover Swoole's public server behavior rather than only parser helpers. Read the interim response before sending the request body and assert the server emits it exactly once. Keep the raw request helpers bounded and close their sockets unconditionally. --- tests/HttpServer/NativeRequestParsingTest.php | 22 ++---- .../HttpServer/Fixtures/server.php | 6 ++ .../HttpServer/RequestConversionTest.php | 28 +++++++ .../HttpServer/RequestFramingTest.php | 75 ++++++++++++++++--- 4 files changed, 103 insertions(+), 28 deletions(-) diff --git a/tests/HttpServer/NativeRequestParsingTest.php b/tests/HttpServer/NativeRequestParsingTest.php index 492fbbe315..eeb39c4d2a 100644 --- a/tests/HttpServer/NativeRequestParsingTest.php +++ b/tests/HttpServer/NativeRequestParsingTest.php @@ -13,6 +13,9 @@ class NativeRequestParsingTest extends TestCase { protected string $temporaryDirectory; + /** + * Prepare an isolated upload directory. + */ protected function setUp(): void { parent::setUp(); @@ -23,6 +26,9 @@ protected function setUp(): void $filesystem->ensureDirectoryExists($this->temporaryDirectory); } + /** + * Remove the isolated upload directory. + */ protected function tearDown(): void { (new Filesystem)->deleteDirectory($this->temporaryDirectory); @@ -127,22 +133,6 @@ public function testRequestFieldsSurviveIncrementalParseCalls(): void } } - public function testQueryRequestsWithBodiesAreParsed(): void - { - // Covered upstream by https://github.com/swoole/swoole-src/pull/6256. - $body = 'search terms'; - $requestData = $this->requestData('QUERY', '/search', [ - 'Content-Type' => 'text/plain', - 'Content-Length' => (string) strlen($body), - ], $body); - $request = Request::create(); - - $this->assertSame(strlen($requestData), $request->parse($requestData)); - $this->assertTrue($request->isCompleted()); - $this->assertSame('QUERY', $request->getMethod()); - $this->assertSame($body, $request->rawContent()); - } - /** * Build one literal HTTP request for the native incremental parser. * diff --git a/tests/Integration/HttpServer/Fixtures/server.php b/tests/Integration/HttpServer/Fixtures/server.php index 745b120799..ece69d2963 100644 --- a/tests/Integration/HttpServer/Fixtures/server.php +++ b/tests/Integration/HttpServer/Fixtures/server.php @@ -156,6 +156,12 @@ ] : null, ]); }); +$router->match(['QUERY'], '/query', fn (Request $request): JsonResponse => new JsonResponse([ + 'method' => $request->getMethod(), + 'query' => $request->query->all(), + 'request' => $request->request->all(), + 'content' => $request->getContent(), +])); $router->get('/context', function (Request $request): string { usleep(20_000); diff --git a/tests/Integration/HttpServer/RequestConversionTest.php b/tests/Integration/HttpServer/RequestConversionTest.php index fb736c5e75..47a233dbac 100644 --- a/tests/Integration/HttpServer/RequestConversionTest.php +++ b/tests/Integration/HttpServer/RequestConversionTest.php @@ -75,6 +75,34 @@ public function testMultipartFieldsAndFilesAreConverted(): void $this->assertSame('uploaded contents', $payload['file']['contents']); } + public function testQueryRequestsAreParsedByTheHttpServer(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires HTTP/1 QUERY support from https://github.com/swoole/swoole-src/pull/6256.'); + } + + $client = new EngineClient($this->getServerHost(), $this->getServerPort()); + $body = 'term=runtime&page=2'; + + try { + $response = $client->request( + 'QUERY', + '/query?source=hypervel', + ['Content-Type' => 'application/x-www-form-urlencoded'], + $body, + ); + } finally { + $client->close(); + } + $this->assertSame(200, $response->statusCode); + $payload = json_decode($response->body, true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame('QUERY', $payload['method']); + $this->assertSame(['source' => 'hypervel'], $payload['query']); + $this->assertSame(['term' => 'runtime', 'page' => '2'], $payload['request']); + $this->assertSame($body, $payload['content']); + } + public function testLargeUploadsRejectClientProvidedInternalMarkers(): void { if (SWOOLE_VERSION_ID <= 60203) { diff --git a/tests/Integration/HttpServer/RequestFramingTest.php b/tests/Integration/HttpServer/RequestFramingTest.php index 7578eb81ce..617a325eb2 100644 --- a/tests/Integration/HttpServer/RequestFramingTest.php +++ b/tests/Integration/HttpServer/RequestFramingTest.php @@ -14,19 +14,28 @@ public function testChunkedRequestsKeepTheirExactBoundary(): void $this->markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6205'); } - $response = $this->sendRawRequest( - "POST /inspect HTTP/1.1\r\n" + $socket = $this->openSocket(); + $headers = "POST /inspect HTTP/1.1\r\n" . "Host: 127.0.0.1\r\n" . "Transfer-Encoding: chunked\r\n" - . "Expect: 100-continue\r\n\r\n" - . "5;name=value\r\nhello\r\n" + . "Expect: 100-continue\r\n\r\n"; + $bodyAndNextRequest = "5;name=value\r\nhello\r\n" . "0\r\nX-Trailer: value\r\n\r\n" . "GET /up HTTP/1.1\r\n" . "Host: 127.0.0.1\r\n" - . "Connection: close\r\n\r\n" - ); + . "Connection: close\r\n\r\n"; + + try { + $this->assertSame(strlen($headers), fwrite($socket, $headers)); + $this->assertStringStartsWith('HTTP/1.1 100 Continue', $this->readResponseHead($socket)); + $this->assertSame(strlen($bodyAndNextRequest), fwrite($socket, $bodyAndNextRequest)); + $response = stream_get_contents($socket); + $this->assertIsString($response); + } finally { + fclose($socket); + } - $this->assertSame(1, substr_count($response, 'HTTP/1.1 100 Continue')); + $this->assertSame(0, substr_count($response, 'HTTP/1.1 100 Continue')); $this->assertSame(2, substr_count($response, 'HTTP/1.1 200 OK')); $this->assertStringContainsString('"content":"hello"', $response); $this->assertStringEndsWith('up', $response); @@ -53,7 +62,33 @@ public function testPipelinedResponsesStayInRequestOrder(): void $this->assertLessThan($second, $first); } + /** + * Send one raw request and read the complete response. + */ protected function sendRawRequest(string $request): string + { + $socket = $this->openSocket(); + + try { + $this->assertSame(strlen($request), fwrite($socket, $request)); + $response = stream_get_contents($socket); + + if (! is_string($response)) { + throw new RuntimeException('Unable to read the HTTP response.'); + } + + return $response; + } finally { + fclose($socket); + } + } + + /** + * Open a bounded connection to the HTTP test server. + * + * @return resource + */ + protected function openSocket() { $socket = stream_socket_client( sprintf('tcp://%s:%d', $this->getServerHost(), $this->getServerPort()), @@ -68,12 +103,28 @@ protected function sendRawRequest(string $request): string stream_set_timeout($socket, 2); - try { - $this->assertSame(strlen($request), fwrite($socket, $request)); + return $socket; + } - return stream_get_contents($socket); - } finally { - fclose($socket); + /** + * Read one HTTP response head. + * + * @param resource $socket + */ + protected function readResponseHead($socket): string + { + $head = ''; + + while (! str_ends_with($head, "\r\n\r\n")) { + $line = fgets($socket); + + if ($line === false) { + throw new RuntimeException('Timed out waiting for the HTTP response head.'); + } + + $head .= $line; } + + return $head; } } From 178f312ca1ed2aea14075d22091d7b54b69e4528 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:45:08 +0000 Subject: [PATCH 09/11] test: make Swoole TLS regressions exact Treat a client without credentials as rejected unless it receives a successful HTTP response, since TLS stacks may report rejection during the handshake or as an immediate EOF. Move malformed certificate startup into a bounded process test and require the fatal ssl_init diagnostic rather than accepting a clean configuration rejection. --- .../HttpServer/Fixtures/tls_server.php | 8 ----- .../Integration/HttpServer/MutualTlsTest.php | 30 ++++++++++++----- tests/Integration/HttpServer/SniTlsTest.php | 21 ++---------- .../Fixtures/invalid_tls_configuration.php | 18 ++++++++++ tests/Server/ServerTlsConfigurationTest.php | 33 +++++++++++++++++++ 5 files changed, 76 insertions(+), 34 deletions(-) create mode 100644 tests/Server/Fixtures/invalid_tls_configuration.php create mode 100644 tests/Server/ServerTlsConfigurationTest.php diff --git a/tests/Integration/HttpServer/Fixtures/tls_server.php b/tests/Integration/HttpServer/Fixtures/tls_server.php index 6518a0b8a6..8fffacb3ea 100644 --- a/tests/Integration/HttpServer/Fixtures/tls_server.php +++ b/tests/Integration/HttpServer/Fixtures/tls_server.php @@ -31,14 +31,6 @@ 'ssl_key_file' => $tlsDirectory . '/server.key', ], ]; -} elseif ($mode === 'invalid') { - $settings += [ - 'ssl_cert_file' => $tlsDirectory . '/invalid.crt', - 'ssl_key_file' => $tlsDirectory . '/server.key', - ]; - - echo @$server->set($settings) ? 'accepted' : 'rejected'; - exit; } else { fwrite(STDERR, "Unknown TLS test mode.\n"); exit(1); diff --git a/tests/Integration/HttpServer/MutualTlsTest.php b/tests/Integration/HttpServer/MutualTlsTest.php index 4b11a449ad..7f4e3802f4 100644 --- a/tests/Integration/HttpServer/MutualTlsTest.php +++ b/tests/Integration/HttpServer/MutualTlsTest.php @@ -15,6 +15,9 @@ class MutualTlsTest extends TestCase protected int $serverPort = 19507; + /** + * Require the mutual TLS test server. + */ protected function setUp(): void { parent::setUp(); @@ -27,11 +30,18 @@ public function testMutualTlsRequiresAClientCertificate(): void $this->markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6188'); } - $this->assertFalse($this->connect()); - $this->assertStringContainsString('secure', $this->connect(withClientCertificate: true)); + $this->assertStringNotContainsString('HTTP/1.1 200 OK', $this->request()); + + $response = $this->request(withClientCertificate: true); + + $this->assertStringContainsString('HTTP/1.1 200 OK', $response); + $this->assertStringEndsWith('secure', $response); } - protected function connect(bool $withClientCertificate = false): string|false + /** + * Send a request with optional client credentials. + */ + protected function request(bool $withClientCertificate = false): string { $tlsDirectory = __DIR__ . '/Fixtures/Tls'; $options = [ @@ -56,14 +66,18 @@ protected function connect(bool $withClientCertificate = false): string|false ); if (! is_resource($socket)) { - return false; + return ''; } stream_set_timeout($socket, 2); - fwrite($socket, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); - $response = stream_get_contents($socket); - fclose($socket); - return $response; + try { + @fwrite($socket, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + $response = @stream_get_contents($socket); + + return is_string($response) ? $response : ''; + } finally { + fclose($socket); + } } } diff --git a/tests/Integration/HttpServer/SniTlsTest.php b/tests/Integration/HttpServer/SniTlsTest.php index eccff2d06d..7cd11f1a5b 100644 --- a/tests/Integration/HttpServer/SniTlsTest.php +++ b/tests/Integration/HttpServer/SniTlsTest.php @@ -6,7 +6,6 @@ use Hypervel\Foundation\Testing\Concerns\InteractsWithServer; use Hypervel\Tests\TestCase; -use Symfony\Component\Process\Process; class SniTlsTest extends TestCase { @@ -16,6 +15,9 @@ class SniTlsTest extends TestCase protected int $serverPort = 19508; + /** + * Require the SNI-only TLS test server. + */ protected function setUp(): void { parent::setUp(); @@ -51,21 +53,4 @@ public function testSniOnlyServersCompleteTlsHandshakes(): void $this->assertStringContainsString('secure', $response); } - - public function testMalformedDefaultCertificatesAreRejected(): void - { - if (SWOOLE_VERSION_ID <= 60203) { - $this->markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6214'); - } - - $process = new Process([ - PHP_BINARY, - __DIR__ . '/Fixtures/tls_server.php', - 'invalid', - '0', - ]); - $process->mustRun(); - - $this->assertSame('rejected', $process->getOutput()); - } } diff --git a/tests/Server/Fixtures/invalid_tls_configuration.php b/tests/Server/Fixtures/invalid_tls_configuration.php new file mode 100644 index 0000000000..115d1c2be4 --- /dev/null +++ b/tests/Server/Fixtures/invalid_tls_configuration.php @@ -0,0 +1,18 @@ +set([ + 'log_file' => '/dev/null', + 'ssl_cert_file' => $tlsDirectory . '/invalid.crt', + 'ssl_key_file' => $tlsDirectory . '/server.key', +]); + +fwrite(STDERR, "Malformed TLS certificate was accepted.\n"); diff --git a/tests/Server/ServerTlsConfigurationTest.php b/tests/Server/ServerTlsConfigurationTest.php new file mode 100644 index 0000000000..4124a2ff31 --- /dev/null +++ b/tests/Server/ServerTlsConfigurationTest.php @@ -0,0 +1,33 @@ +markTestSkipped('Requires failed TLS context initialization from https://github.com/swoole/swoole-src/pull/6214.'); + } + + $process = new Process([ + PHP_BINARY, + __DIR__ . '/Fixtures/invalid_tls_configuration.php', + ]); + $process->setTimeout(2); + $process->run(); + + $this->assertFalse($process->isSuccessful()); + $this->assertStringContainsString( + 'ssl_init() failed', + $process->getOutput() . $process->getErrorOutput(), + ); + } +} From 3e4186d10baad6d9ee6efca363a036e25a1f6ed1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:23:23 +0000 Subject: [PATCH 10/11] test: harden Swoole regression failures Bound the hooked stream shutdown read while retaining the EOF assertion that distinguishes a timeout from a successful half-close. Prove large multipart uploads reach Swoole's preprocessing path before sending the forged internal marker over a raw connection. This prevents package-size handling or HTTP client behavior from producing a false pass. --- tests/Engine/HookedStreamTest.php | 1 + .../HttpServer/RequestConversionTest.php | 52 ++++++++++++++++--- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/tests/Engine/HookedStreamTest.php b/tests/Engine/HookedStreamTest.php index e3249fbb14..6be84fcb1b 100644 --- a/tests/Engine/HookedStreamTest.php +++ b/tests/Engine/HookedStreamTest.php @@ -24,6 +24,7 @@ public function testSocketShutdownReportsTheNativeResult(): void try { $this->assertIsResource($sender); $this->assertIsResource($receiver); + stream_set_timeout($receiver, 1); $this->assertSame(7, fwrite($sender, 'payload')); $this->assertTrue(stream_socket_shutdown($sender, STREAM_SHUT_WR)); $this->assertSame('payload', stream_get_contents($receiver)); diff --git a/tests/Integration/HttpServer/RequestConversionTest.php b/tests/Integration/HttpServer/RequestConversionTest.php index 47a233dbac..fac2052a90 100644 --- a/tests/Integration/HttpServer/RequestConversionTest.php +++ b/tests/Integration/HttpServer/RequestConversionTest.php @@ -114,21 +114,57 @@ public function testLargeUploadsRejectClientProvidedInternalMarkers(): void $filesystem->ensureDirectoryExists($directory); $probe = $directory . '/probe.txt'; $filesystem->put($probe, 'probe'); + $contents = str_repeat('x', 80 * 1024); try { - $response = $this->request('POST', '/inspect', [ + $valid = $this->decode($this->request('POST', '/inspect', [ 'multipart' => [[ 'name' => 'upload', - 'contents' => str_repeat('x', 80 * 1024), + 'contents' => $contents, 'filename' => 'large.txt', - 'headers' => [ - 'Content-Type' => 'text/plain', - 'Swoole-Upload-File' => $probe, - ], + 'headers' => ['Content-Type' => 'text/plain'], ]], + ])); + + $this->assertSame($contents, $valid['file']['contents']); + + $boundary = '------------------------hypervel-upload'; + $body = implode("\r\n", [ + '--' . $boundary, + 'Content-Disposition: form-data; name="upload"; filename="large.txt"', + 'Content-Type: text/plain', + 'Swoole-Upload-File: ' . $probe, + '', + $contents, + '--' . $boundary . '--', + '', ]); - - $this->assertSame(400, $response->getStatusCode()); + $request = implode("\r\n", [ + 'POST /inspect HTTP/1.1', + 'Host: ' . $this->getServerHost(), + 'Connection: close', + 'Content-Type: multipart/form-data; boundary=' . $boundary, + 'Content-Length: ' . strlen($body), + '', + $body, + ]); + $socket = stream_socket_client(sprintf( + 'tcp://%s:%d', + $this->getServerHost(), + $this->getServerPort(), + )); + $this->assertIsResource($socket); + stream_set_timeout($socket, 2); + + try { + $this->assertSame(strlen($request), fwrite($socket, $request)); + $response = stream_get_contents($socket); + $this->assertIsString($response); + } finally { + fclose($socket); + } + + $this->assertStringContainsString('400 Bad Request', $response); $this->assertFileExists($probe); $this->assertSame('probe', $filesystem->get($probe)); } finally { From add25a62a4b889bc0f6866c54d670c2ef1bcb256 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:23:28 +0000 Subject: [PATCH 11/11] test: track read-through stream leaks by identity Check for newly surviving stream resources after promotion cancellation instead of requiring every process-wide baseline stream to remain open. Keep the direct source and upload-wrapper closure assertions while preserving coverage for the adapter's internal spool. --- tests/Filesystem/FilesystemManagerTest.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index 112e1ec938..91c6d6fd6a 100644 --- a/tests/Filesystem/FilesystemManagerTest.php +++ b/tests/Filesystem/FilesystemManagerTest.php @@ -829,7 +829,7 @@ public function testReadThroughDiskPromotionFailuresAreBestEffortByDefault(): vo public function testReadThroughPromotionCancellationClosesOwnedStreams(): void { - $streamCount = count(get_resources('stream')); + $initialStreamIds = array_keys(get_resources('stream')); $source = fopen('php://temp', 'w+b'); fwrite($source, 'contents'); rewind($source); @@ -856,7 +856,10 @@ static function (string $path, mixed $stream) use (&$temporary, $cancellation): $this->assertFalse(is_resource($source)); $this->assertNotNull($temporary); $this->assertFalse(is_resource($temporary)); - $this->assertCount($streamCount, get_resources('stream')); + $this->assertCount( + 0, + array_diff_key(get_resources('stream'), array_fill_keys($initialStreamIds, true)), + ); } finally { if (is_resource($source)) { fclose($source);