diff --git a/.run/Main.run.xml b/.run/Main.run.xml index 99253aa..966ab4a 100644 --- a/.run/Main.run.xml +++ b/.run/Main.run.xml @@ -2,7 +2,7 @@ - diff --git a/composer.json b/composer.json index c741b15..d387105 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,9 @@ "lcobucci/jwt": "^5.6", "adhocore/cli": "^1.9", "robinvdvleuten/ulid": "^5.0", - "monolog/monolog": "^3.9" + "monolog/monolog": "^3.9", + "react/promise": "^3", + "react/async": "^4" }, "require-dev": { "phpunit/phpunit": "^12.4", diff --git a/composer.lock b/composer.lock index 2667d82..3db9c91 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7c2d40400d6f4d0469324dc1645eba3c", + "content-hash": "3554ee67a6c8a798d673b42dc9de3093", "packages": [ { "name": "adhocore/cli", @@ -1798,6 +1798,226 @@ }, "time": "2021-10-29T13:26:27+00:00" }, + { + "name": "react/async", + "version": "v4.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/async.git", + "reference": "635d50e30844a484495713e8cb8d9e079c0008a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/async/zipball/635d50e30844a484495713e8cb8d9e079c0008a5", + "reference": "635d50e30844a484495713e8cb8d9e079c0008a5", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.8 || ^1.2.1" + }, + "require-dev": { + "phpstan/phpstan": "1.10.39", + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Async\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async utilities and fibers for ReactPHP", + "keywords": [ + "async", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/async/issues", + "source": "https://github.com/reactphp/async/tree/v4.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-04T14:40:02+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2023-11-13T13:48:05+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, { "name": "roadrunner-php/app-logger", "version": "1.2.0", diff --git a/src/Events/Dispatcher.php b/src/Events/Dispatcher.php index e5b561c..2de241e 100644 --- a/src/Events/Dispatcher.php +++ b/src/Events/Dispatcher.php @@ -9,6 +9,9 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Collection; use Siteworxpro\App\Attributes\Events\ListensFor; +use function React\Async\await; +use function React\Async\coroutine; + /** * Class Dispatcher * @@ -29,6 +32,8 @@ class Dispatcher implements DispatcherContract, Arrayable */ private Collection $pushed; + private array $subscribers = []; + /** * @var string LISTENERS_NAMESPACE The namespace where listeners are located */ @@ -99,7 +104,7 @@ class Dispatcher implements DispatcherContract, Arrayable */ public function subscribe($subscriber): void { - $this->listeners = array_merge($this->listeners, (array) $subscriber); + $this->subscribers[] = $subscriber; } /** @@ -108,6 +113,7 @@ class Dispatcher implements DispatcherContract, Arrayable * @param $event * @param array $payload * @return array|null + * @throws \Throwable */ public function until($event, $payload = []): array|null { @@ -121,6 +127,7 @@ class Dispatcher implements DispatcherContract, Arrayable * @param array $payload * @param bool $halt * @return array|null + * @throws \Throwable */ public function dispatch($event, $payload = [], $halt = false): array|null { @@ -130,23 +137,46 @@ class Dispatcher implements DispatcherContract, Arrayable $eventClass = $event; } + // Handle subscribers as a coroutine + $promise = coroutine(function () use ($event, $payload, $halt, $eventClass, &$responses) { + foreach ($this->subscribers as $subscriber) { + if (method_exists($subscriber, 'handle')) { + $response = $subscriber->handle($event, $payload); + $responses[$eventClass] = $response; + + if ($halt && $response !== null) { + return $responses; + } + } + } + + return null; + }); + $listeners = $this->listeners[$eventClass] ?? null; + // If no listeners, just await the subscriber promise if ($listeners === null) { - return null; + return await($promise); } $responses = []; - foreach ($listeners as $listener) { $response = $listener($event, $payload); - $responses[] = $response; + $responses[$eventClass] = $response; if ($halt && $response !== null) { return $response; } } + // Await the subscriber promise and merge responses + $promiseResponses = await($promise); + + if (is_array($promiseResponses)) { + $responses = array_merge($responses, $promiseResponses); + } + return $responses; } @@ -167,6 +197,7 @@ class Dispatcher implements DispatcherContract, Arrayable * * @param $event * @return void + * @throws \Throwable */ public function flush($event): void { diff --git a/src/Events/Listeners/Database/Connected.php b/src/Events/Listeners/Database/Connected.php index 94915cc..f7844e9 100644 --- a/src/Events/Listeners/Database/Connected.php +++ b/src/Events/Listeners/Database/Connected.php @@ -18,12 +18,15 @@ use Siteworxpro\App\Services\Facades\Logger; class Connected extends Listener { /** - * @param ConnectionEvent $event + * @param mixed $event * @param array $payload * @return null */ - public function __invoke($event, array $payload = []): null + public function __invoke(mixed $event, array $payload = []): null { + if (!($event instanceof ConnectionEvent)) { + throw new \TypeError("Invalid event type passed to listener " . static::class); + } Logger::info("Database connection event", [get_class($event), $event->connectionName]); diff --git a/tests/Controllers/IndexControllerTest.php b/tests/Controllers/IndexControllerTest.php index ff28901..2639a6d 100644 --- a/tests/Controllers/IndexControllerTest.php +++ b/tests/Controllers/IndexControllerTest.php @@ -22,4 +22,19 @@ class IndexControllerTest extends AbstractController $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('{"status_code":200,"message":"Server is running"}', (string)$response->getBody()); } + + /** + * @throws \JsonException + */ + public function testPost(): void + { + $this->assertTrue(true); + + $controller = new IndexController(); + + $response = $controller->post($this->getMockRequest()); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('{"status_code":200,"message":"Server is running"}', (string)$response->getBody()); + } } diff --git a/tests/Events/Listeners/ConnectedTest.php b/tests/Events/Listeners/ConnectedTest.php new file mode 100644 index 0000000..866698a --- /dev/null +++ b/tests/Events/Listeners/ConnectedTest.php @@ -0,0 +1,30 @@ +expectNotToPerformAssertions(); + + $connectedEvent = $this->createMock(ConnectionEstablished::class); + $listener = new Connected(); + + $listener->__invoke($connectedEvent); + } + + public function testThrowsException() + { + $this->expectException(\TypeError::class); + $listener = new Connected(); + $listener->__invoke(new \stdClass()); + } +} diff --git a/tests/Http/Middleware/CorsMiddlewareTest.php b/tests/Http/Middleware/CorsMiddlewareTest.php index 3163ec4..4eecdd5 100644 --- a/tests/Http/Middleware/CorsMiddlewareTest.php +++ b/tests/Http/Middleware/CorsMiddlewareTest.php @@ -11,7 +11,7 @@ use Siteworxpro\App\Http\Middleware\CorsMiddleware; use Siteworxpro\App\Services\Facades\Config; use Siteworxpro\Tests\Unit; -class CorsMiddlewareTest extends Unit +class CorsMiddlewareTest extends Middleware { public function testAllowsConfiguredOrigin(): void { @@ -80,22 +80,4 @@ class CorsMiddlewareTest extends Unit $this->assertEquals('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); } - - private function mockHandler(Response $response): RequestHandlerInterface - { - return new class ($response) implements RequestHandlerInterface { - private Response $response; - - public function __construct(Response $response) - { - $this->response = $response; - } - - public function handle( - \Psr\Http\Message\ServerRequestInterface $request - ): \Psr\Http\Message\ResponseInterface { - return $this->response; - } - }; - } } diff --git a/tests/Http/Middleware/JwtMiddlewareTest.php b/tests/Http/Middleware/JwtMiddlewareTest.php new file mode 100644 index 0000000..e0d840d --- /dev/null +++ b/tests/Http/Middleware/JwtMiddlewareTest.php @@ -0,0 +1,255 @@ +shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/'); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::OK->value, $response->getStatusCode()); + } + + /** + * @throws \JsonException + */ + public function testIgnoresJwtAttributeButNoToken() + { + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/'); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode()); + } + + /** + * @throws \JsonException + */ + public function testInvalidToken() + { + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . 'invalid_token_string'); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode()); + $this->assertStringContainsString( + 'Unauthorized: Invalid token', + $response->getBody()->getContents() + ); + } + + /** + * @throws \JsonException + */ + public function testJwtAttributeWithTokenButWrongAud() + { + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt()); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode()); + $this->assertStringContainsString( + 'The token is not allowed to be used by this audience', + $response->getBody()->getContents() + ); + } + + /** + * @throws \JsonException + */ + public function testJwtAttributeWithTokenButWrongIss() + { + Config::set('jwt.audience', 'https://client-app.io'); + + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt()); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode()); + $this->assertStringContainsString( + 'The token was not issued by the given issuers', + $response->getBody()->getContents() + ); + } + + /** + * @throws \JsonException + */ + public function testJwtAttributeWithTokenWithDiffIssuer() + { + Config::set('jwt.audience', 'https://client-app.io'); + Config::set('jwt.issuer', 'https://different-issuer.io'); + + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt()); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode()); + $this->assertStringContainsString( + 'The token was not issued by the given issuers', + $response->getBody()->getContents() + ); + } + + public function testJwtAttributeWithToken() + { + Config::set('jwt.audience', 'https://client-app.io'); + Config::set('jwt.issuer', 'https://api.my-awesome-app.io'); + + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt()); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::OK->value, $response->getStatusCode()); + } + + private function getJwt(): string + { + $key = InMemory::plainText(self::TEST_SIGNING_KEY); + $signer = new Sha256(); + + $token = new JwtFacade()->issue( + $signer, + $key, + static fn ( + Builder $builder, + DateTimeImmutable $issuedAt + ): Builder => $builder + ->issuedBy('https://api.my-awesome-app.io') + ->permittedFor('https://client-app.io') + ->expiresAt($issuedAt->modify('+10 minutes')) + ); + + return $token->toString(); + } +} diff --git a/tests/Http/Middleware/Middleware.php b/tests/Http/Middleware/Middleware.php new file mode 100644 index 0000000..cfb69b5 --- /dev/null +++ b/tests/Http/Middleware/Middleware.php @@ -0,0 +1,32 @@ +response = $response; + } + + public function handle( + ServerRequestInterface $request + ): ResponseInterface { + return $this->response; + } + }; + } +} diff --git a/tests/Http/Middleware/ScopeMiddlewareTest.php b/tests/Http/Middleware/ScopeMiddlewareTest.php new file mode 100644 index 0000000..57de8d9 --- /dev/null +++ b/tests/Http/Middleware/ScopeMiddlewareTest.php @@ -0,0 +1,111 @@ +shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/'); + $middleware = new ScopeMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * @throws \ReflectionException + * @throws \JsonException + */ + public function testAllowsWithScope() + { + $class = new class { + public function getCallable(): array + { + return [ $this, 'index' ]; + } + + #[Scope(['admin'])] + public function index() + { + // Dummy method for testing + } + }; + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/')->withAttribute('scopes', ['admin', 'user']); + $middleware = new ScopeMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::OK->value, $response->getStatusCode()); + } + + /** + * @throws \ReflectionException + * @throws \JsonException + */ + public function testDisallowsWithScope() + { + $class = new class { + public function getCallable(): array + { + return [ $this, 'index' ]; + } + + #[Scope(['admin'])] + public function index() + { + // Dummy method for testing + } + }; + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $request = new ServerRequest('GET', '/'); + $middleware = new ScopeMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::FORBIDDEN->value, $response->getStatusCode()); + } +}