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/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. 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); + 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)); + $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/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/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..b36fe48fa9 100644 --- a/tests/Engine/WebSocketTest.php +++ b/tests/Engine/WebSocketTest.php @@ -65,6 +65,85 @@ public function testFrameModelsTheNativeMaskFlag(): void $this->assertTrue(Frame::from($swooleFrame)->getMask()); } + public function testNativeFramesDecodeMaskedCompressedPayloadsWithoutMutatingTheInput(): void + { + 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 = 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, bin2hex($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 (! 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) { + $invalid = $packed; + $invalid[0] = chr((ord($invalid[0]) & 0xF0) | $opcode); + + $this->assertNotSame(0, ord($invalid[0]) & 0x40); + $this->assertSame($opcode, ord($invalid[0]) & 0x0F); + $this->assertFalse(@SwooleFrame::unpack($invalid)); + } + } + public function testResponseReturnsTheNativeResponsePushResult(): void { $connection = new EngineTestSwooleResponse; 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); diff --git a/tests/HttpServer/NativeRequestParsingTest.php b/tests/HttpServer/NativeRequestParsingTest.php new file mode 100644 index 0000000000..eeb39c4d2a --- /dev/null +++ b/tests/HttpServer/NativeRequestParsingTest.php @@ -0,0 +1,154 @@ +temporaryDirectory = ParallelTesting::tempDir('NativeRequestParsingTest'); + $filesystem = new Filesystem; + $filesystem->deleteDirectory($this->temporaryDirectory); + $filesystem->ensureDirectoryExists($this->temporaryDirectory); + } + + /** + * Remove the isolated upload directory. + */ + 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()); + } + } + + /** + * 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]); + } +} diff --git a/tests/Integration/Engine/ClientTest.php b/tests/Integration/Engine/ClientTest.php index 466c91c1ce..37fe33e31a 100644 --- a/tests/Integration/Engine/ClientTest.php +++ b/tests/Integration/Engine/ClientTest.php @@ -134,4 +134,57 @@ 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(); + } + } } 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(); 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/server.php b/tests/Integration/HttpServer/Fixtures/server.php index 4202cafb07..ece69d2963 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(), @@ -153,11 +156,23 @@ ] : 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); 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/Fixtures/tls_server.php b/tests/Integration/HttpServer/Fixtures/tls_server.php new file mode 100644 index 0000000000..8fffacb3ea --- /dev/null +++ b/tests/Integration/HttpServer/Fixtures/tls_server.php @@ -0,0 +1,46 @@ + '/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', + ], + ]; +} 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..7f4e3802f4 --- /dev/null +++ b/tests/Integration/HttpServer/MutualTlsTest.php @@ -0,0 +1,83 @@ +setUpInteractsWithServer(); + } + + public function testMutualTlsRequiresAClientCertificate(): void + { + if (SWOOLE_VERSION_ID <= 60203) { + $this->markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6188'); + } + + $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); + } + + /** + * Send a request with optional client credentials. + */ + protected function request(bool $withClientCertificate = false): string + { + $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 ''; + } + + stream_set_timeout($socket, 2); + + 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/RequestConversionTest.php b/tests/Integration/HttpServer/RequestConversionTest.php index f609ddb63a..fac2052a90 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,103 @@ 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) { + $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'); + $contents = str_repeat('x', 80 * 1024); + + try { + $valid = $this->decode($this->request('POST', '/inspect', [ + 'multipart' => [[ + 'name' => 'upload', + 'contents' => $contents, + 'filename' => 'large.txt', + '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 . '--', + '', + ]); + $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 { + $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..617a325eb2 --- /dev/null +++ b/tests/Integration/HttpServer/RequestFramingTest.php @@ -0,0 +1,130 @@ +markTestSkipped('Requires https://github.com/swoole/swoole-src/pull/6205'); + } + + $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"; + $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"; + + 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(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); + } + + 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); + } + + /** + * 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()), + $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); + + return $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; + } +} diff --git a/tests/Integration/HttpServer/SniTlsTest.php b/tests/Integration/HttpServer/SniTlsTest.php new file mode 100644 index 0000000000..7cd11f1a5b --- /dev/null +++ b/tests/Integration/HttpServer/SniTlsTest.php @@ -0,0 +1,56 @@ +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); + } +} 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(), + ); + } +}