From 6b0ec9f43c5ea1ba52edb21edf1014089b80310a Mon Sep 17 00:00:00 2001 From: Stephane Roussel Date: Wed, 5 Aug 2026 11:21:00 +0200 Subject: [PATCH] [UAT-replay-security-hardening] fix(promises): support react/promise 2 and 3 --- Http/Statement.php | 42 ++++++++-- Tests/Unit/StatementPromiseHandlersTest.php | 93 +++++++++++++++++++++ composer.json | 2 +- 3 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 Tests/Unit/StatementPromiseHandlersTest.php diff --git a/Http/Statement.php b/Http/Statement.php index 17a76ab..d06b7a3 100644 --- a/Http/Statement.php +++ b/Http/Statement.php @@ -16,6 +16,7 @@ use Psr\Log\LoggerAwareTrait; use React\Promise\Deferred; use React\Promise\Promise; +use React\Promise\PromiseInterface; use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; @@ -397,7 +398,7 @@ public function attachFile($key, $filepath, $mimetype = null, $clientName = null */ public function onSuccess(callable $callable) { - $this->getPromise()->then($callable); + $this->consumePromise($this->getPromise()->then($callable)); return $this; } @@ -410,7 +411,7 @@ public function onSuccess(callable $callable) */ public function onError(callable $callable) { - $this->getPromise()->then(null, $callable); + $this->consumePromise($this->getPromise()->then(null, $callable)); return $this; } @@ -423,7 +424,22 @@ public function onError(callable $callable) */ public function onProgress(callable $callable) { - $this->getPromise()->then(null, null, $callable); + $promise = $this->getPromise(); + + if (method_exists(object_or_class: $promise, method: 'progress')) { + $progressPromise = $promise->progress($callable); + + if ($progressPromise instanceof PromiseInterface) { + $this->consumePromise($progressPromise); + } + + return $this; + } + + $this->logger?->warning( + 'Statement::onProgress() is ignored because the installed react/promise runtime does not support progress callbacks.' + ); + return $this; } @@ -437,13 +453,29 @@ public function onProgress(callable $callable) public function onFinish(callable $callable) { if (method_exists(object_or_class: $this->getPromise(), method: 'finally')) { - $this->getPromise()->finally($callable); + $this->consumePromise($this->getPromise()->finally($callable)); } else { - $this->getPromise()->always($callable); + $this->consumePromise($this->getPromise()->always($callable)); } return $this; } + /** + * React Promise v3 reports unhandled rejections on discarded child promises. + */ + private function consumePromise(PromiseInterface $promise): void + { + if (method_exists(object_or_class: $promise, method: 'catch')) { + $promise->catch(static function (): void { + }); + + return; + } + + $promise->otherwise(static function (): void { + }); + } + /** * @param string $consumerKey diff --git a/Tests/Unit/StatementPromiseHandlersTest.php b/Tests/Unit/StatementPromiseHandlersTest.php new file mode 100644 index 0000000..f130ee6 --- /dev/null +++ b/Tests/Unit/StatementPromiseHandlersTest.php @@ -0,0 +1,93 @@ +warnings[] = (string) $message; + } + } + }; + } + + public function testProxiedHandlersConsumeRejectedChildPromises(): void + { + $logger = $this->createLogger(); + + $statement = new Statement( + Request::create('/status/404', 'GET'), + new EventDispatcher(), + ); + $statement->setLogger($logger); + + $events = []; + + $statement + ->onSuccess(static function () use (&$events): void { + $events[] = 'success'; + }) + ->onError(static function () use (&$events): void { + $events[] = 'error'; + }) + ->onFinish(static function () use (&$events): void { + $events[] = 'done'; + }); + + $statement->setError(new NotFoundHttpException('Not found')); + + self::assertSame(['error', 'done'], $events); + self::assertSame([], $logger->warnings); + } + + public function testOnProgressIsCompatibleAcrossPromiseVersions(): void + { + $logger = $this->createLogger(); + $statement = new Statement( + Request::create('/status/200', 'GET'), + new EventDispatcher(), + ); + $statement->setLogger($logger); + + $progressEvents = []; + + self::assertSame( + $statement, + $statement->onProgress(static function ($payload) use (&$progressEvents): void { + $progressEvents[] = $payload; + }) + ); + + $deferredProperty = new ReflectionProperty(Statement::class, 'deferred'); + $deferred = $deferredProperty->getValue($statement); + + if (method_exists($statement->getPromise(), 'progress') && method_exists($deferred, 'notify')) { + $deferred->notify('tick'); + + self::assertSame(['tick'], $progressEvents); + self::assertSame([], $logger->warnings); + + return; + } + + self::assertSame([], $progressEvents); + self::assertCount(1, $logger->warnings); + self::assertStringContainsString('onProgress() is ignored', $logger->warnings[0]); + } +} diff --git a/composer.json b/composer.json index 45e681f..b416afa 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "homepage": "https://github.com/evaisse/SimpleHttpBundle", "require": { "php": "^8.1", - "react/promise": "^2.11", + "react/promise": "^2.2 || ^3.0", "twig/twig": "^3.27", "symfony/http-foundation": "^5.4 || ^6.0 || ^7.0", "symfony/browser-kit": "^5.4 || ^6.0 || ^7.0",