From a650d0489ad8fb8b8d0c695f329bed4458056454 Mon Sep 17 00:00:00 2001 From: Jeremy Lindblom Date: Thu, 22 Apr 2021 14:17:14 -0700 Subject: [PATCH] Improvements and additions to AppServers and Deferrers - Improved `AppServer` implementations: - Added overrideable `init()` method to assist in initialization since constructor is final. - Added support for setting AppCredentials on the base AppServer. - Improved `Deferrer` implementations: - Moved `PreAckDeferrer` to new `Deferral` namespace. - Added `ShellExecDeferrer` to allow deferring via a background `shell_exec` call. - Added `DeferredContextCliServer` for processing deferred `Context`s via CLI. - Fixed initialization bug in `Ack` listener. - Improved `MultiTenantHttpServer` implementation: - Added support for more lenient/intuitive app registrations. - Added `Coerce::application()` helper. - Improved `Auth` components: - Fixed bug in `AuthMiddleware` that validated credentials too early. - Updated `AppCredentials::supports*()` methods. - Added missing `App::withBotToken()` method. --- src/App.php | 15 +++ src/AppConfig.php | 4 + src/AppServer.php | 67 ++++++++++++- src/Auth/AppCredentials.php | 31 +++--- src/Deferral/DeferredContextCliServer.php | 95 +++++++++++++++++++ src/{ => Deferral}/PreAckDeferrer.php | 4 +- src/Deferral/ShellExecDeferrer.php | 54 +++++++++++ src/Http/AppHandler.php | 3 +- src/Http/AuthMiddleware.php | 8 +- src/Http/HttpServer.php | 55 +++-------- src/Http/MultiTenantHttpServer.php | 66 +++++++------ src/Listeners/Ack.php | 4 +- src/Router.php | 2 +- tests/Fakes/FakeResponseEmitter.php | 41 ++++++++ tests/Integration/Apps/AnyApp.php | 14 +++ tests/Integration/Apps/any-app.php | 5 + .../DeferredContextCliServerTest.php | 43 +++++++++ tests/Integration/IntegTestCase.php | 61 +++--------- .../Integration/MultiTenantHttpServerTest.php | 53 +++++++++++ 19 files changed, 473 insertions(+), 152 deletions(-) create mode 100644 src/Deferral/DeferredContextCliServer.php rename src/{ => Deferral}/PreAckDeferrer.php (92%) create mode 100644 src/Deferral/ShellExecDeferrer.php create mode 100644 tests/Fakes/FakeResponseEmitter.php create mode 100644 tests/Integration/Apps/AnyApp.php create mode 100644 tests/Integration/Apps/any-app.php create mode 100644 tests/Integration/DeferredContextCliServerTest.php create mode 100644 tests/Integration/MultiTenantHttpServerTest.php diff --git a/src/App.php b/src/App.php index dfc327e..b0b9060 100644 --- a/src/App.php +++ b/src/App.php @@ -217,6 +217,21 @@ public function withAppToken(string $appToken): self return $this; } + /** + * Explicitly sets the both token to use for Auth. + * + * You can also set this via the environment variable: SLACK_BOT_TOKEN. + * + * @param string $botToken + * @return $this + */ + public function withBotToken(string $botToken): self + { + $this->config->withBotToken($botToken); + + return $this; + } + /** * Sets the app credentials for the app. * diff --git a/src/AppConfig.php b/src/AppConfig.php index b791225..5508800 100644 --- a/src/AppConfig.php +++ b/src/AppConfig.php @@ -201,6 +201,10 @@ public function getTokenStore(): TokenStore } /** + * Explicitly sets the both token to use for Auth. + * + * You can also set this via the environment variable: SLACK_BOT_TOKEN. + * * @param string $botToken * @return $this */ diff --git a/src/AppServer.php b/src/AppServer.php index ea77756..0037a1f 100644 --- a/src/AppServer.php +++ b/src/AppServer.php @@ -5,13 +5,14 @@ namespace SlackPhp\Framework; use Psr\Log\{LoggerInterface, NullLogger}; +use SlackPhp\Framework\Auth\{AppCredentials, AppCredentialsStore}; /** * An AppServer is a protocol-specific and/or framework-specific app runner. * * Its main responsibilities include: * 1. Receiving an incoming Slack request via the specific protocol/framework. - * 2. Authenticating the Slack request. + * 2. Authentication, including incoming Slack requests or outgoing connections. * 3. Parsing the Slack request and payload into a Slack `Context`. * 4. Using the app to process the Slack Context. * 5. Providing a protocol-specific way for the app to "ack" back to Slack. @@ -20,9 +21,12 @@ abstract class AppServer { private ?Application $app; + private ?AppCredentialsStore $appCredentialsStore; private ?LoggerInterface $logger; /** + * Creates a new instance of the server for fluent configuration. + * * @return static */ public static function new(): self @@ -30,9 +34,14 @@ public static function new(): self return new static(); } + /** + * Creates the server. + * + * Cannot override. If initialization logic is needed, override the `init()` method. + */ final public function __construct() { - // Do nothing. App and Logger are initialized lazily. + $this->init(); } /** @@ -52,7 +61,7 @@ public function withApp($app): self } /** - * Gets the logger for the Server + * Gets the application being run by the Server * * @return Application */ @@ -70,6 +79,39 @@ protected function getApp(): Application return $this->app; } + /** + * Sets the app credentials store for the Server. + * + * @param AppCredentialsStore $appCredentialsStore + * @return $this + */ + public function withAppCredentialsStore(AppCredentialsStore $appCredentialsStore): self + { + $this->appCredentialsStore = $appCredentialsStore; + + return $this; + } + + /** + * Gets the app credentials to use for authenticating the app being run by the Server. + * + * If app credentials are not provided in the AppConfig, the app credentials store will be used to fetch them. + * + * @return AppCredentials + */ + protected function getAppCredentials(): AppCredentials + { + $config = $this->getApp()->getConfig(); + $credentials = $config->getAppCredentials(); + + if (!$credentials->supportsAnyAuth() && isset($this->appCredentialsStore)) { + $credentials = $this->appCredentialsStore->getAppCredentials($config->getId()); + $config->withAppCredentials($credentials); + } + + return $credentials; + } + /** * Sets the logger for the Server. * @@ -84,6 +126,8 @@ public function withLogger(LoggerInterface $logger): self } /** + * Gets the logger for the Server. + * * @return LoggerInterface */ protected function getLogger(): LoggerInterface @@ -95,6 +139,16 @@ protected function getLogger(): LoggerInterface : $this->logger; } + /** + * Initializes a server. Called at the time of construction. + * + * Implementations MAY override. + */ + protected function init(): void + { + // Do nothing by default. + } + /** * Starts receiving and processing requests from Slack. */ @@ -103,7 +157,10 @@ abstract public function start(): void; /** * Stops receiving requests from Slack. * - * Depending on the implementation, `stop()` may not need to actually do anything. + * Implementations MAY override. */ - abstract public function stop(): void; + public function stop(): void + { + // Do nothing by default. + } } diff --git a/src/Auth/AppCredentials.php b/src/Auth/AppCredentials.php index a6ccf89..7be1a1a 100644 --- a/src/Auth/AppCredentials.php +++ b/src/Auth/AppCredentials.php @@ -6,6 +6,9 @@ use SlackPhp\Framework\Env; +/** + * Contains credentials required for all types of app authentication. + */ class AppCredentials { /** @var array */ @@ -155,30 +158,29 @@ public function supportsApiAuth(): bool return isset($this->defaultBotToken); } - public function supportsInstallationOAuth(): bool + public function supportsInstallAuth(): bool { return isset($this->clientId, $this->clientSecret); } - /** - * @return string|null - */ + public function supportsAnyAuth(): bool + { + return $this->supportsHttpAuth() + || $this->supportsApiAuth() + || $this->supportsInstallAuth() + || $this->supportsSocketAuth(); + } + public function getAppToken(): ?string { return $this->appToken; } - /** - * @return string|null - */ public function getClientId(): ?string { return $this->clientId; } - /** - * @return string|null - */ public function getClientSecret(): ?string { return $this->clientSecret; @@ -192,25 +194,16 @@ public function getCustomSecrets(): array return $this->customSecrets; } - /** - * @return string|null - */ public function getDefaultBotToken(): ?string { return $this->defaultBotToken; } - /** - * @return string - */ public function getSigningKey(): ?string { return $this->signingKey; } - /** - * @return string - */ public function getStateSecret(): ?string { return $this->stateSecret; diff --git a/src/Deferral/DeferredContextCliServer.php b/src/Deferral/DeferredContextCliServer.php new file mode 100644 index 0000000..61d9d23 --- /dev/null +++ b/src/Deferral/DeferredContextCliServer.php @@ -0,0 +1,95 @@ +args = $args; + + return $this; + } + + /** + * @param callable(string): Context $deserializeCallback + * @return $this + */ + public function withDeserializeCallback(callable $deserializeCallback): self + { + $this->deserializeCallback = Closure::fromCallable($deserializeCallback); + + return $this; + } + + protected function init(): void + { + global $argv; + $this->args = $argv ?? []; + } + + public function start(): void + { + try { + $this->getLogger()->debug('Started processing of deferred context'); + $context = $this->deserializeContext($this->args[1] ?? ''); + $this->getApp()->handle($context); + $this->getLogger()->debug('Completed processing of deferred context'); + } catch (Throwable $exception) { + $this->getLogger()->error('Error occurred during processing of deferred context', compact('exception')); + $this->exitCode = 1; + } + + $this->stop(); + } + + public function stop(): void + { + if (isset($this->args[2]) && $this->args[2] === '--soft-exit') { + return; + } + + exit($this->exitCode); + } + + private function deserializeContext(string $serializedContext): Context + { + $fn = $this->deserializeCallback ?? function (string $serializedContext): Context { + if (strlen($serializedContext) === 0) { + throw new Exception('No context provided'); + } + + $data = json_decode(base64_decode($serializedContext), true); + if (empty($data)) { + throw new Exception('Invalid context data'); + } + + $context = Context::fromArray($data); + if (!($context->isAcknowledged() && $context->isDeferred())) { + throw new Exception('Context was not deferred'); + } + + return $context; + }; + + return $fn($serializedContext); + } +} diff --git a/src/PreAckDeferrer.php b/src/Deferral/PreAckDeferrer.php similarity index 92% rename from src/PreAckDeferrer.php rename to src/Deferral/PreAckDeferrer.php index 14874f9..35eba05 100644 --- a/src/PreAckDeferrer.php +++ b/src/Deferral/PreAckDeferrer.php @@ -2,7 +2,9 @@ declare(strict_types=1); -namespace SlackPhp\Framework; +namespace SlackPhp\Framework\Deferral; + +use SlackPhp\Framework\{Context, Deferrer, Listener}; /** * A synchronous implementation of Deferrer, that does the additional processing prior to the "ack" HTTP response. diff --git a/src/Deferral/ShellExecDeferrer.php b/src/Deferral/ShellExecDeferrer.php new file mode 100644 index 0000000..8619c82 --- /dev/null +++ b/src/Deferral/ShellExecDeferrer.php @@ -0,0 +1,54 @@ +script = $script; + $this->dir = $dir; + if (!is_dir($this->dir)) { + throw new Exception('Invalid dir for deferrer script'); + } + + $this->serializeCallback = $serializeCallback ? Closure::fromCallable($serializeCallback) : null; + } + + public function defer(Context $context): void + { + $context->logger()->debug('Deferring processing by running a command with shell_exec in the background'); + $data = escapeshellarg($this->serializeContext($context)); + $command = "cd {$this->dir};nohup {$this->script} {$data} > /dev/null 2>&1 &"; + shell_exec($command); + } + + private function serializeContext(Context $context): string + { + $fn = $this->serializeCallback ?? fn (Context $ctx): string => base64_encode(json_encode($ctx->toArray())); + + return $fn($context); + } +} diff --git a/src/Http/AppHandler.php b/src/Http/AppHandler.php index b918c9c..23271b1 100644 --- a/src/Http/AppHandler.php +++ b/src/Http/AppHandler.php @@ -5,7 +5,8 @@ namespace SlackPhp\Framework\Http; use Psr\Http\Message\{ResponseInterface, ServerRequestInterface}; -use SlackPhp\Framework\{Application, Context, Deferrer, PreAckDeferrer}; +use SlackPhp\Framework\{Application, Context, Deferrer}; +use SlackPhp\Framework\Deferral\PreAckDeferrer; use Nyholm\Psr7\Response; use Psr\Http\Server\RequestHandlerInterface as HandlerInterface; diff --git a/src/Http/AuthMiddleware.php b/src/Http/AuthMiddleware.php index b3b9ef7..f27e503 100644 --- a/src/Http/AuthMiddleware.php +++ b/src/Http/AuthMiddleware.php @@ -19,9 +19,6 @@ class AuthMiddleware implements MiddlewareInterface public function __construct(AppCredentials $appCredentials) { $this->appCredentials = $appCredentials; - if (!$this->appCredentials->supportsHttpAuth()) { - throw new AuthException('No signing key provided', 401); - } } /** @@ -41,6 +38,11 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface return $handler->handle($request); } + // Ensure the necessary credentials have been supplied. + if (!$this->appCredentials->supportsHttpAuth()) { + throw new AuthException('No signing key provided', 401); + } + // Validate the signature. $this->getAuthContext($request)->validate($this->appCredentials->getSigningKey()); diff --git a/src/Http/HttpServer.php b/src/Http/HttpServer.php index 0b203de..5003edc 100644 --- a/src/Http/HttpServer.php +++ b/src/Http/HttpServer.php @@ -4,7 +4,6 @@ namespace SlackPhp\Framework\Http; -use SlackPhp\Framework\Auth\{AppCredentials, AppCredentialsStore}; use SlackPhp\Framework\{Deferrer, AppServer}; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; @@ -15,18 +14,14 @@ class HttpServer extends AppServer { - private ?AppCredentialsStore $appCredentialsStore; private ?Deferrer $deferrer; private ?ResponseEmitter $emitter; private ?ServerRequestInterface $request; - public function withAppCredentialsStore(AppCredentialsStore $appCredentialsStore): self - { - $this->appCredentialsStore = $appCredentialsStore; - - return $this; - } - + /** + * @param Deferrer $deferrer + * @return $this + */ public function withDeferrer(Deferrer $deferrer): self { $this->deferrer = $deferrer; @@ -34,6 +29,10 @@ public function withDeferrer(Deferrer $deferrer): self return $this; } + /** + * @param ServerRequestInterface $request + * @return $this + */ public function withRequest(ServerRequestInterface $request): self { $this->request = $request; @@ -41,6 +40,10 @@ public function withRequest(ServerRequestInterface $request): self return $this; } + /** + * @param ResponseEmitter $emitter + * @return $this + */ public function withResponseEmitter(ResponseEmitter $emitter): self { $this->emitter = $emitter; @@ -64,16 +67,6 @@ public function start(): void $this->emitResponse($response); } - /** - * Stops receiving requests from Slack. - * - * Depending on the implementation, `stop()` may not need to do anything. - */ - public function stop(): void - { - // No action necessary, since PHP's typical request lifecycle ends automatically. - } - /** * Gets a representation of the request data from super globals. * @@ -94,7 +87,7 @@ protected function getRequest(): ServerRequestInterface return $this->request; } - protected function emitResponse(ResponseInterface $response): void + private function emitResponse(ResponseInterface $response): void { $emitter = $this->emitter ?? new EchoResponseEmitter(); $emitter->emit($response); @@ -111,26 +104,4 @@ private function getHandler(): HandlerInterface return Util::applyMiddleware($handler, [new AuthMiddleware($this->getAppCredentials())]); } - - /** - * Gets AppCredentials for use by Auth. - * - * Determines the AppCredentials by reconciling configured credentials on the AppConfig and the AppCredentialsStore - * configured on the AppServer. - * - * @return AppCredentials - */ - private function getAppCredentials(): AppCredentials - { - $config = $this->getApp()->getConfig(); - - $credentials = $config->getAppCredentials(); - if (!$credentials->supportsHttpAuth() && isset($this->appCredentialsStore)) { - $credentials = $this->appCredentialsStore->getAppCredentials($config->getId()); - } - - $config->withAppCredentials($credentials); - - return $credentials; - } } diff --git a/src/Http/MultiTenantHttpServer.php b/src/Http/MultiTenantHttpServer.php index a3bff1b..232ba30 100644 --- a/src/Http/MultiTenantHttpServer.php +++ b/src/Http/MultiTenantHttpServer.php @@ -5,20 +5,25 @@ namespace SlackPhp\Framework\Http; use Closure; -use Nyholm\Psr7\Response; use Psr\Http\Message\ServerRequestInterface; -use SlackPhp\Framework\Application; -use Throwable; +use SlackPhp\Framework\{Application, Coerce}; class MultiTenantHttpServer extends HttpServer { private const APP_ID_KEY = '_app'; - /** @var array */ + /** @var array */ private array $apps = []; private ?Closure $appIdDetector; - public function registerApp(string $appId, callable $appFactory): self + /** + * Register an app by app ID to be routed to. + * + * @param string $appId + * @param string|callable(): Application $appFactory App class name, include file, or factory callback. + * @return $this + */ + public function registerApp(string $appId, $appFactory): self { $this->apps[$appId] = $appFactory; @@ -38,20 +43,6 @@ public function withAppIdDetector(callable $appIdDetector): self return $this; } - /** - * Starts receiving and processing requests from Slack. - */ - public function start(): void - { - try { - parent::start(); - } catch (Throwable $exception) { - $response = new Response($exception->getCode() ?: 500); - $this->getLogger()->error('Error responding to incoming Slack request', compact('exception')); - $this->emitResponse($response); - } - } - protected function getApp(): Application { // Get the app ID from the request. @@ -60,17 +51,8 @@ protected function getApp(): Application throw new HttpException('Cannot determine app ID'); } - // Make sure an app was registered for the app ID. - $appFactory = $this->apps[$appId] ?? null; - if ($appFactory === null) { - throw new HttpException("No app registered for app ID: {$appId}"); - } - - // Create the app from its configured factory, and make sure it's valid. - $app = $appFactory(); - if (!$app instanceof Application) { - throw new HttpException("Invalid application for app ID: {$appId}"); - } + // Create the app for the app ID. + $app = $this->instantiateApp($appId); // Reconcile the registered app ID with the App's configured ID. $configuredId = $app->getConfig()->getId(); @@ -86,6 +68,30 @@ protected function getApp(): Application return parent::getApp(); } + /** + * @param string $appId ID for the application + * @return Application + * @noinspection PhpIncludeInspection + */ + private function instantiateApp(string $appId): Application + { + // Create the app from its configured factory, and make sure it's valid. + $factory = $this->apps[$appId] ?? null; + if (is_null($factory)) { + throw new HttpException("No app registered for app ID: {$appId}"); + } elseif (is_string($factory) && class_exists($factory)) { + $app = new $factory(); + } elseif (is_string($factory) && is_file($factory)) { + $app = require $factory; + } elseif (is_callable($factory)) { + $app = $factory(); + } else { + throw new HttpException("Invalid application for app ID: {$appId}"); + } + + return Coerce::application($app); + } + private function getAppIdDetector(): Closure { return $this->appIdDetector ?? function (ServerRequestInterface $request): ?string { diff --git a/src/Listeners/Ack.php b/src/Listeners/Ack.php index 21e52af..f6bedf4 100644 --- a/src/Listeners/Ack.php +++ b/src/Listeners/Ack.php @@ -21,9 +21,7 @@ class Ack implements Listener */ public function __construct($message = null) { - if ($message !== null) { - $this->message = Coerce::message($message); - } + $this->message = $message ? Coerce::message($message) : null; } public function handle(Context $context): void diff --git a/src/Router.php b/src/Router.php index 022986d..0958da2 100644 --- a/src/Router.php +++ b/src/Router.php @@ -84,7 +84,7 @@ public function command(string $name, $listener): self */ public function commandAsync(string $name, $listener): self { - return $this->command($name, Route::Async($listener, $this->commandAck)); + return $this->command($name, Route::async($listener, $this->commandAck)); } /** diff --git a/tests/Fakes/FakeResponseEmitter.php b/tests/Fakes/FakeResponseEmitter.php new file mode 100644 index 0000000..f070305 --- /dev/null +++ b/tests/Fakes/FakeResponseEmitter.php @@ -0,0 +1,41 @@ +fn = $fn ? Closure::fromCallable($fn) : null; + $this->lastResponse = null; + } + + public function emit(ResponseInterface $response): void + { + $this->lastResponse = $response; + if ($this->fn !== null) { + ($this->fn)($response); + } + } + + public function getLastResponse(): ?ResponseInterface + { + return $this->lastResponse; + } + + public function getLastResponseData(): array + { + if ($this->lastResponse === null) { + return []; + } + + return json_decode((string) $this->lastResponse->getBody(), true) ?? []; + } +} diff --git a/tests/Integration/Apps/AnyApp.php b/tests/Integration/Apps/AnyApp.php new file mode 100644 index 0000000..e500ff8 --- /dev/null +++ b/tests/Integration/Apps/AnyApp.php @@ -0,0 +1,14 @@ +any(fn (Context $ctx) => $ctx->ack('hello')); + } +} + diff --git a/tests/Integration/Apps/any-app.php b/tests/Integration/Apps/any-app.php new file mode 100644 index 0000000..9a95dbe --- /dev/null +++ b/tests/Integration/Apps/any-app.php @@ -0,0 +1,5 @@ +any(fn (Context $ctx) => $ctx->ack('hello')); diff --git a/tests/Integration/DeferredContextCliServerTest.php b/tests/Integration/DeferredContextCliServerTest.php new file mode 100644 index 0000000..9575757 --- /dev/null +++ b/tests/Integration/DeferredContextCliServerTest.php @@ -0,0 +1,43 @@ + true, + '_deferred' => true, + '_payload' => [ + 'command' => '/foo', + 'response_url' => 'https://example.org', + ], + ])); + + $respondClient = $this->createMock(RespondClient::class); + $respondClient->expects($this->once()) + ->method('respond') + ->with( + 'https://example.org', + $this->callback(fn ($v): bool => $v instanceof Message && strpos($v->toJson(), 'bar') !== false) + ); + + $app = App::new() + ->commandAsync('foo', fn (Context $ctx) => $ctx->respond('bar')) + ->tap(function (Context $ctx) use ($respondClient) { + $ctx->withRespondClient($respondClient); + }); + DeferredContextCliServer::new() + ->withApp($app) + ->withArgs(['script', $serializedContext, '--soft-exit']) + ->start(); + } +} diff --git a/tests/Integration/IntegTestCase.php b/tests/Integration/IntegTestCase.php index ce04955..3631743 100644 --- a/tests/Integration/IntegTestCase.php +++ b/tests/Integration/IntegTestCase.php @@ -3,20 +3,21 @@ namespace SlackPhp\Framework\Tests\Integration; use SlackPhp\Framework\Context; -use SlackPhp\Framework\Clients\ApiClient; -use SlackPhp\Framework\Clients\RespondClient; -use SlackPhp\Framework\Contexts\DataBag; -use SlackPhp\Framework\Http\HttpServer; -use SlackPhp\Framework\Http\ResponseEmitter; -use SlackPhp\Framework\Interceptor; -use SlackPhp\Framework\Interceptors\Tap; -use SlackPhp\BlockKit\Surfaces\Message; + use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; +use SlackPhp\BlockKit\Surfaces\Message; +use SlackPhp\Framework\Clients\ApiClient; +use SlackPhp\Framework\Clients\RespondClient; +use SlackPhp\Framework\Contexts\DataBag; +use SlackPhp\Framework\Http\HttpServer; +use SlackPhp\Framework\Interceptor; +use SlackPhp\Framework\Interceptors\Tap; +use SlackPhp\Framework\Tests\Fakes\FakeResponseEmitter; class IntegTestCase extends TestCase { @@ -25,14 +26,10 @@ class IntegTestCase extends TestCase private const HEADER_SIGNATURE = 'X-Slack-Signature'; private const HEADER_TIMESTAMP = 'X-Slack-Request-Timestamp'; - /** @var Psr17Factory */ - protected $httpFactory; - + protected Psr17Factory $httpFactory; /** @var LoggerInterface|MockObject */ protected $logger; - - /** @var ResponseInterface|null */ - protected $lastResponse; + private FakeResponseEmitter $responseEmitter; public function setUp(): void { @@ -42,11 +39,12 @@ public function setUp(): void parent::setUp(); $this->httpFactory = new Psr17Factory(); $this->logger = $this->createMock(LoggerInterface::class); + $this->responseEmitter = new FakeResponseEmitter(); } protected function parseResponse(?ResponseInterface $response = null): DataBag { - $response = $response ?? $this->getLastResponse(); + $response = $response ?? $this->responseEmitter->getLastResponse(); $content = (string) $response->getBody(); if ($content === '') { @@ -60,18 +58,6 @@ protected function parseResponse(?ResponseInterface $response = null): DataBag } } - protected function getLastResponse(): ResponseInterface - { - if ($this->lastResponse) { - $response = $this->lastResponse; - $this->lastResponse = null; - - return $response; - } - - $this->fail('There was no last response'); - } - protected function createCommandRequest(array $data, ?int $timestamp = null): ServerRequestInterface { return $this->createRequest(http_build_query($data), 'application/x-www-form-urlencoded', $timestamp); @@ -108,29 +94,10 @@ private function createRequest(string $content, string $contentType, ?int $times protected function createHttpServer(ServerRequestInterface $request): HttpServer { - $setLastResponse = function (ResponseInterface $response): void { - $this->lastResponse = $response; - }; - - $emitter = new class($setLastResponse) implements ResponseEmitter { - /** @var callable */ - private $fn; - - public function __construct(callable $fn) - { - $this->fn = $fn; - } - - public function emit(ResponseInterface $response): void - { - ($this->fn)($response); - } - }; - return HttpServer::new() ->withLogger($this->logger) ->withRequest($request) - ->withResponseEmitter($emitter); + ->withResponseEmitter($this->responseEmitter); } protected function failOnLoggedErrors(): void diff --git a/tests/Integration/MultiTenantHttpServerTest.php b/tests/Integration/MultiTenantHttpServerTest.php new file mode 100644 index 0000000..ad2dd07 --- /dev/null +++ b/tests/Integration/MultiTenantHttpServerTest.php @@ -0,0 +1,53 @@ +request = new ServerRequest('POST', '/', ['Content-Type' => 'application/json'], '{}'); + $this->responseEmitter = new FakeResponseEmitter(); + $this->server = MultiTenantHttpServer::new() + ->registerApp('A1', Apps\AnyApp::class) + ->registerApp('A2', __DIR__ . '/Apps/any-app.php') + ->registerApp('A3', fn () => new Apps\AnyApp()) + ->withResponseEmitter($this->responseEmitter); + } + + protected function tearDown(): void + { + putenv('SLACKPHP_SKIP_AUTH='); + parent::tearDown(); + } + + public function testCanRunAppFromClassName(): void + { + $this->server->withRequest($this->request->withQueryParams(['_app' => 'A1']))->start(); + $this->assertArrayHasKey('blocks', $this->responseEmitter->getLastResponseData()); + } + + public function testCanRunAppFromInclude(): void + { + $this->server->withRequest($this->request->withQueryParams(['_app' => 'A2']))->start(); + $this->assertArrayHasKey('blocks', $this->responseEmitter->getLastResponseData()); + } + + public function testCanRunAppFromCallback(): void + { + $this->server->withRequest($this->request->withQueryParams(['_app' => 'A3']))->start(); + $this->assertArrayHasKey('blocks', $this->responseEmitter->getLastResponseData()); + } +}