diff --git a/src/Pdk/Hooks/PdkWebhookHooks.php b/src/Pdk/Hooks/PdkWebhookHooks.php index d6235f0a5..d27d4acbb 100644 --- a/src/Pdk/Hooks/PdkWebhookHooks.php +++ b/src/Pdk/Hooks/PdkWebhookHooks.php @@ -9,6 +9,7 @@ use MyParcelNL\Pdk\Facade\Pdk; use MyParcelNL\WooCommerce\Hooks\Concern\UsesPdkRequestConverter; use MyParcelNL\WooCommerce\Hooks\Contract\WordPressHooksInterface; +use Symfony\Component\HttpFoundation\Request; use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; @@ -29,11 +30,11 @@ public function apply(): void */ public function processWebhookRequest(WP_REST_Request $request): WP_REST_Response { - Logger::info('Webhook received', ['request' => $request->get_params()]); + Logger::info('Incoming webhook', ['request' => $request->get_params()]); /** @var \MyParcelNL\Pdk\App\Webhook\PdkWebhookManager $webhooks */ $webhooks = Pdk::get(PdkWebhookManager::class); - $webhooks->call($this->convertRequest($request)); + $webhooks->call($this->normalizeRequest($request)); $response = new WP_REST_Response(); $response->set_status(202); @@ -56,4 +57,19 @@ public function registerWebhookRoutes(): void ] ); } + + /** + * WordPress strips the wp-json prefix from the route, but we expect it to be present in the url to validate the + * webhook request. + * + * @param \WP_REST_Request $wpRestRequest + * + * @return \Symfony\Component\HttpFoundation\Request + */ + private function normalizeRequest(WP_REST_Request $wpRestRequest): Request + { + $wpRestRequest->set_route("/wp-json{$wpRestRequest->get_route()}"); + + return $this->convertRequest($wpRestRequest); + } } diff --git a/src/Pdk/Plugin/Action/WcWebhookService.php b/src/Pdk/Plugin/Action/WcWebhookService.php index 96c5e6472..8689fb7d2 100644 --- a/src/Pdk/Plugin/Action/WcWebhookService.php +++ b/src/Pdk/Plugin/Action/WcWebhookService.php @@ -16,7 +16,7 @@ public function getBaseUrl(): string { return get_rest_url( null, - sprintf('%s/%s', Pdk::get('routeBackend'), Pdk::get('routeBackendWebhook')) + sprintf('%s/%s', Pdk::get('routeBackend'), Pdk::get('routeBackendWebhookBase')) ); } } diff --git a/src/Pdk/WcPdkBootstrapper.php b/src/Pdk/WcPdkBootstrapper.php index 47ebc94fb..8cd4d18e9 100644 --- a/src/Pdk/WcPdkBootstrapper.php +++ b/src/Pdk/WcPdkBootstrapper.php @@ -224,7 +224,10 @@ protected function getAdditionalConfig( 'routeBackend' => value("$name/backend/v1"), 'routeBackendPdk' => value('pdk'), - 'routeBackendWebhook' => value('webhook/(?P.+)'), + 'routeBackendWebhookBase' => value('webhook'), + 'routeBackendWebhook' => factory(function (): string { + return sprintf('%s/(?P.+)', Pdk::get('routeBackendWebhookBase')); + }), 'routeBackendPermissionCallback' => factory(static function (): string { if (! is_user_logged_in()) { return '__return_false'; diff --git a/tests/Mock/MockWcClass.php b/tests/Mock/MockWcClass.php index 2599488f3..4b874caca 100644 --- a/tests/Mock/MockWcClass.php +++ b/tests/Mock/MockWcClass.php @@ -4,24 +4,16 @@ namespace MyParcelNL\WooCommerce\Tests\Mock; -use BadMethodCallException; use MyParcelNL\Pdk\Base\Support\Arr; -use MyParcelNL\Sdk\src\Support\Str; use WC_Data; abstract class MockWcClass extends WC_Data { - private const GETTER_PREFIX = 'get_'; - - /** - * @var array - */ - protected $attributes = []; + use MocksGettersAndSetters; /** * @param array|int|string $data - extra types to avoid type errors in real code. * - * @noinspection PhpMissingParentConstructorInspection * @throws \Throwable */ public function __construct($data = []) @@ -40,37 +32,6 @@ public function __construct($data = []) $this->fill($data); } - /** - * @param $name - * @param $arguments - * - * @return null|mixed - */ - public function __call($name, $arguments) - { - if (Str::startsWith($name, ['is_', 'needs_'])) { - $method = self::GETTER_PREFIX . $name; - - return $this->{$method}(); - } - - if (Str::startsWith($name, self::GETTER_PREFIX)) { - $attribute = substr($name, strlen(self::GETTER_PREFIX)); - - return $this->attributes[$attribute] ?? null; - } - - throw new BadMethodCallException("Method $name does not exist"); - } - - /** - * @return array - */ - public function getAttributes(): array - { - return $this->attributes; - } - /** * @return null|int */ @@ -147,7 +108,7 @@ public function update_meta_data($key, $value, $meta_id = 0): void * * @return void */ - private function fill(array $data): void + protected function fill(array $data): void { $this->attributes = array_replace($this->attributes, Arr::except($data, 'meta')); diff --git a/tests/Mock/MockWpClass.php b/tests/Mock/MockWpClass.php new file mode 100644 index 000000000..c369ec335 --- /dev/null +++ b/tests/Mock/MockWpClass.php @@ -0,0 +1,10 @@ +fill([ + 'route' => $route, + 'method' => $method, + 'attributes' => $attributes, + 'params' => [], + 'headers' => [], + 'file_params' => [], + ]); + } + + public function get_attributes(): array + { + return $this->attributes; + } +} diff --git a/tests/Mock/MockWpRestResponse.php b/tests/Mock/MockWpRestResponse.php new file mode 100644 index 000000000..c50b93685 --- /dev/null +++ b/tests/Mock/MockWpRestResponse.php @@ -0,0 +1,28 @@ +fill([ + 'data' => $data, + 'status' => $status, + 'headers' => $headers, + ]); + } +} diff --git a/tests/Mock/MockWpRestServer.php b/tests/Mock/MockWpRestServer.php new file mode 100644 index 000000000..b2612dc3a --- /dev/null +++ b/tests/Mock/MockWpRestServer.php @@ -0,0 +1,55 @@ +routes; + } + + /** + * @param string $route_namespace + * @param string $route + * @param array $args + * @param bool $override + * + * @return void + */ + public function register_route( + string $route_namespace, + string $route, + array $args = [], + bool $override = false + ): void { + $path = "$route_namespace/$route"; + + $this->routes[$path] = [ + 'override' => $override, + 'args' => $args, + ]; + } + + public function reset(): void + { + $this->routes = []; + } +} diff --git a/tests/Mock/MocksGettersAndSetters.php b/tests/Mock/MocksGettersAndSetters.php new file mode 100644 index 000000000..4362c2a9f --- /dev/null +++ b/tests/Mock/MocksGettersAndSetters.php @@ -0,0 +1,107 @@ + + */ + protected $attributes = []; + + /** + * @param $name + * @param $arguments + * + * @return null|mixed + */ + public function __call($name, $arguments) + { + if (Str::startsWith($name, ['is_', 'needs_'])) { + $method = self::$getterPrefix . $name; + + return $this->{$method}(); + } + + if (Str::startsWith($name, self::$getterPrefix)) { + return $this->getAttribute($name); + } + + if (Str::startsWith($name, self::$setterPrefix)) { + $this->setAttribute($name, $arguments[0]); + + return null; + } + + throw new BadMethodCallException("Method $name does not exist"); + } + + /** + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * @param array $data + * + * @return void + */ + protected function fill(array $data): void + { + $this->attributes = array_replace($this->attributes, $data); + } + + /** + * @param string $name + * + * @return null|mixed + */ + private function getAttribute(string $name) + { + $attribute = $this->getAttributeName($name, self::$getterPrefix); + + return $this->attributes[$attribute] ?? null; + } + + /** + * @param string $method + * @param string $prefix + * + * @return string + */ + private function getAttributeName(string $method, string $prefix): string + { + return substr($method, strlen($prefix)); + } + + /** + * @param string $name + * @param mixed $value + * + * @return void + */ + private function setAttribute(string $name, $value): void + { + $attribute = $this->getAttributeName($name, self::$setterPrefix); + + $this->attributes[$attribute] = $value; + } +} diff --git a/tests/Unit/Pdk/Hooks/PdkWebhookHooksTest.php b/tests/Unit/Pdk/Hooks/PdkWebhookHooksTest.php new file mode 100644 index 000000000..24806d3fd --- /dev/null +++ b/tests/Unit/Pdk/Hooks/PdkWebhookHooksTest.php @@ -0,0 +1,126 @@ + get(MockCronService::class), +])); + +function sendWebhook(string $route): void +{ + /** @var \MyParcelNL\WooCommerce\Pdk\Hooks\PdkWebhookHooks $hookClass */ + $hookClass = Pdk::get(PdkWebhookHooks::class); + /** @var \MyParcelNL\Pdk\Tests\Bootstrap\MockPdkWebhooksRepository $webhooksRepository */ + $webhooksRepository = Pdk::get(PdkWebhooksRepositoryInterface::class); + + /** + * The stored url contains the /wp-json/ prefix. + * + * @see \MyParcelNL\WooCommerce\Pdk\Hooks\PdkWebhookHooks::normalizeRequest + */ + $webhooksRepository->storeHashedUrl('/wp-json/myparcelnl/backend/v1/webhook/a-valid-hash'); + + $request = new WP_REST_Request('POST', $route); + $request->set_headers(['x-myparcel-hook' => WebhookSubscription::SHOP_UPDATED]); + $request->set_body(json_encode(['data' => ['hooks' => [['event' => WebhookSubscription::SHOP_UPDATED]]]])); + + $hookClass->processWebhookRequest($request); +} + +it('registers webhook handler in wp rest api', function () { + /** @var \MyParcelNL\WooCommerce\Pdk\Hooks\PdkWebhookHooks $hookClass */ + $hookClass = Pdk::get(PdkWebhookHooks::class); + $hookClass->apply(); + + expect(MockWpActions::get('rest_api_init'))->toBeArray(); + + MockWpActions::execute('rest_api_init'); + + $routes = rest_get_server()->get_routes(); + + expect($routes)->toEqual([ + 'myparcelnl/backend/v1/webhook/(?P.+)' => [ + 'override' => false, + 'args' => [ + 'callback' => [$hookClass, 'processWebhookRequest'], + 'methods' => 'creatable', + 'permission_callback' => '__return_true', + ], + ], + ]); +}); + +it('returns 202 on any incoming webhook', function () { + /** @var \MyParcelNL\WooCommerce\Pdk\Hooks\PdkWebhookHooks $hookClass */ + $hookClass = Pdk::get(PdkWebhookHooks::class); + + $request = new WP_REST_Request('POST', '/myparcelnl/backend/v1/webhook/some-random-hash'); + + $result = $hookClass->processWebhookRequest($request); + + expect($result->get_status())->toBe(202); +}); + +it('schedules a cron job for an incoming webhook', function () { + /** @var \MyParcelNL\Pdk\Tests\Bootstrap\MockCronService $cronService */ + $cronService = Pdk::get(CronServiceInterface::class); + + expect($cronService->getScheduledTasks())->toHaveLength(0); + sendWebhook('/myparcelnl/backend/v1/webhook/some-hash'); + expect($cronService->getScheduledTasks())->toHaveLength(1); +}); + +it('executes the cron job for a valid incoming webhook', function () { + /** @var \MyParcelNL\Pdk\Tests\Bootstrap\MockCronService $cronService */ + $cronService = Pdk::get(CronServiceInterface::class); + /** @var \MyParcelNL\Pdk\Tests\Bootstrap\MockLogger $logger */ + $logger = Pdk::get(PdkLoggerInterface::class); + + sendWebhook('/myparcelnl/backend/v1/webhook/a-valid-hash'); + + $cronService->executeAllTasks(); + + expect($cronService->getScheduledTasks()) + ->toHaveLength(0) + ->and(Arr::pluck($logger->getLogs(), 'message')) + ->toEqual([ + '[PDK]: Incoming webhook', + '[PDK]: Webhook received', + '[PDK]: Webhook processed', + ]); +}); + +it('executes the cron job for an invalid incoming webhook', function () { + /** @var \MyParcelNL\Pdk\Tests\Bootstrap\MockCronService $cronService */ + $cronService = Pdk::get(CronServiceInterface::class); + /** @var \MyParcelNL\Pdk\Tests\Bootstrap\MockLogger $logger */ + $logger = Pdk::get(PdkLoggerInterface::class); + + sendWebhook('/myparcelnl/backend/v1/webhook/invalid-hash'); + + $cronService->executeAllTasks(); + + expect($cronService->getScheduledTasks()) + ->toHaveLength(0) + ->and(Arr::pluck($logger->getLogs(), 'message')) + ->toEqual([ + '[PDK]: Incoming webhook', + '[PDK]: Webhook received with invalid url', + ]); +}); diff --git a/tests/Uses/UsesMockWcPdkInstance.php b/tests/Uses/UsesMockWcPdkInstance.php index 198e3fdc1..74c68a82d 100644 --- a/tests/Uses/UsesMockWcPdkInstance.php +++ b/tests/Uses/UsesMockWcPdkInstance.php @@ -45,14 +45,14 @@ protected function setup(): void private function getConfig(): array { return array_replace( - $this->config, [ CronServiceInterface::class => get(WpCronService::class), PdkAuditRepositoryInterface::class => get(WcPdkAuditRepository::class), PdkOrderNoteRepositoryInterface::class => get(WcOrderNoteRepository::class), PdkOrderRepositoryInterface::class => get(PdkOrderRepository::class), PdkProductRepositoryInterface::class => get(WcPdkProductRepository::class), - ] + ], + $this->config ); } } diff --git a/tests/mock_class_map.php b/tests/mock_class_map.php index 599cd59e4..e28132ffb 100644 --- a/tests/mock_class_map.php +++ b/tests/mock_class_map.php @@ -11,6 +11,9 @@ use MyParcelNL\WooCommerce\Tests\Mock\MockWcOrder; use MyParcelNL\WooCommerce\Tests\Mock\MockWcProduct; use MyParcelNL\WooCommerce\Tests\Mock\MockWcSession; +use MyParcelNL\WooCommerce\Tests\Mock\MockWpRestRequest; +use MyParcelNL\WooCommerce\Tests\Mock\MockWpRestResponse; +use MyParcelNL\WooCommerce\Tests\Mock\MockWpRestServer; /** @see \MyParcelNL\WooCommerce\bootPdk() */ const PEST = true; @@ -46,3 +49,12 @@ class WC extends MockWc { } /** @see \WC_Session */ class WC_Session extends MockWcSession { } + +/** @see \WP_REST_Server */ +class WP_REST_Server extends MockWpRestServer { } + +/** @see \WP_REST_Request */ +class WP_REST_Request extends MockWpRestRequest { } + +/** @see \WP_REST_Response */ +class WP_REST_Response extends MockWpRestResponse { } diff --git a/tests/mock_wp_functions.php b/tests/mock_wp_functions.php index 5eba63275..68ce20ca1 100644 --- a/tests/mock_wp_functions.php +++ b/tests/mock_wp_functions.php @@ -7,6 +7,7 @@ use MyParcelNL\WooCommerce\Tests\Exception\DieException; use MyParcelNL\WooCommerce\Tests\Mock\MockWpActions; use MyParcelNL\WooCommerce\Tests\Mock\MockWpMeta; +use MyParcelNL\WooCommerce\Tests\Mock\MockWpRestServer; use MyParcelNL\WooCommerce\Tests\Mock\MockWpUser; use MyParcelNL\WooCommerce\Tests\Mock\WordPressOptions; use MyParcelNL\WooCommerce\Tests\Mock\WordPressScheduledTasks; @@ -151,3 +152,20 @@ function wp_unslash($value) { return $value; } + +/** + * @return \WP_REST_Server|MockWpRestServer + * @see \rest_get_server() + */ +function rest_get_server(): MockWpRestServer +{ + return MockWpRestServer::getInstance(); +} + +/** + * @see \register_rest_route() + */ +function register_rest_route(...$args): void +{ + rest_get_server()->register_route(...$args); +}