Merge pull request #273 from woocommerce/webhook-simulation

Webhook simulation
This commit is contained in:
Emili Castells 2021-09-24 09:28:01 +02:00 committed by GitHub
commit 7aac842c80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1024 additions and 45 deletions

View file

@ -35,6 +35,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\PaymentTokenFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\SellerStatusFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookEventFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookFactory;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
use WooCommerce\PayPalCommerce\ApiClient\Helper\DccApplies;
@ -114,6 +115,7 @@ return array(
$container->get( 'api.host' ),
$container->get( 'api.bearer' ),
$container->get( 'api.factory.webhook' ),
$container->get( 'api.factory.webhook-event' ),
$container->get( 'woocommerce.logger.woocommerce' )
);
},
@ -214,6 +216,9 @@ return array(
'api.factory.webhook' => static function ( $container ): WebhookFactory {
return new WebhookFactory();
},
'api.factory.webhook-event' => static function ( $container ): WebhookEventFactory {
return new WebhookEventFactory();
},
'api.factory.capture' => static function ( $container ): CaptureFactory {
$amount_factory = $container->get( 'api.factory.amount' );

View file

@ -11,8 +11,10 @@ namespace WooCommerce\PayPalCommerce\ApiClient\Endpoint;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Webhook;
use WooCommerce\PayPalCommerce\ApiClient\Entity\WebhookEvent;
use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookEventFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookFactory;
use Psr\Log\LoggerInterface;
@ -44,6 +46,13 @@ class WebhookEndpoint {
*/
private $webhook_factory;
/**
* The webhook event factory.
*
* @var WebhookEventFactory
*/
private $webhook_event_factory;
/**
* The logger.
*
@ -54,22 +63,25 @@ class WebhookEndpoint {
/**
* WebhookEndpoint constructor.
*
* @param string $host The host.
* @param Bearer $bearer The bearer.
* @param WebhookFactory $webhook_factory The webhook factory.
* @param LoggerInterface $logger The logger.
* @param string $host The host.
* @param Bearer $bearer The bearer.
* @param WebhookFactory $webhook_factory The webhook factory.
* @param WebhookEventFactory $webhook_event_factory The webhook event factory.
* @param LoggerInterface $logger The logger.
*/
public function __construct(
string $host,
Bearer $bearer,
WebhookFactory $webhook_factory,
WebhookEventFactory $webhook_event_factory,
LoggerInterface $logger
) {
$this->host = $host;
$this->bearer = $bearer;
$this->webhook_factory = $webhook_factory;
$this->logger = $logger;
$this->host = $host;
$this->bearer = $bearer;
$this->webhook_factory = $webhook_factory;
$this->webhook_event_factory = $webhook_event_factory;
$this->logger = $logger;
}
/**
@ -189,6 +201,51 @@ class WebhookEndpoint {
return wp_remote_retrieve_response_code( $response ) === 204;
}
/**
* Request a simulated webhook to be sent.
*
* @param Webhook $hook The webhook subscription to use.
* @param string $event_type The event type, such as CHECKOUT.ORDER.APPROVED.
*
* @return WebhookEvent
* @throws RuntimeException If the request fails.
* @throws PayPalApiException If the request fails.
*/
public function simulate( Webhook $hook, string $event_type ): WebhookEvent {
$bearer = $this->bearer->bearer();
$url = trailingslashit( $this->host ) . 'v1/notifications/simulate-event';
$args = array(
'method' => 'POST',
'headers' => array(
'Authorization' => 'Bearer ' . $bearer->token(),
'Content-Type' => 'application/json',
),
'body' => wp_json_encode(
array(
'webhook_id' => $hook->id(),
'event_type' => $event_type,
)
),
);
$response = $this->request( $url, $args );
if ( is_wp_error( $response ) ) {
throw new RuntimeException(
__( 'Not able to simulate webhook.', 'woocommerce-paypal-payments' )
);
}
$json = json_decode( $response['body'] );
$status_code = (int) wp_remote_retrieve_response_code( $response );
if ( 202 !== $status_code ) {
throw new PayPalApiException(
$json,
$status_code
);
}
return $this->webhook_event_factory->from_paypal_response( $json );
}
/**
* Verifies if a webhook event is legitimate.
*

View file

@ -0,0 +1,170 @@
<?php
/**
* The Webhook event notification object.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Entity
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
use DateTime;
use stdClass;
/**
* Class WebhookEvent
*/
class WebhookEvent {
/**
* The ID of the event notification.
*
* @var string
*/
private $id;
/**
* The date and time when the event notification was created.
*
* @var DateTime|null
*/
private $create_time;
/**
* The name of the resource related to the webhook notification event, such as 'checkout-order'.
*
* @var string
*/
private $resource_type;
/**
* The event version in the webhook notification, such as '1.0'.
*
* @var string
*/
private $event_version;
/**
* The event that triggered the webhook event notification, such as 'CHECKOUT.ORDER.APPROVED'.
*
* @var string
*/
private $event_type;
/**
* A summary description for the event notification.
*
* @var string
*/
private $summary;
/**
* The resource version in the webhook notification, such as '1.0'.
*
* @var string
*/
private $resource_version;
/**
* The resource that triggered the webhook event notification.
*
* @var stdClass
*/
private $resource;
/**
* WebhookEvent constructor.
*
* @param string $id The ID of the event notification.
* @param DateTime|null $create_time The date and time when the event notification was created.
* @param string $resource_type The name of the resource related to the webhook notification event, such as 'checkout-order'.
* @param string $event_version The event version in the webhook notification, such as '1.0'.
* @param string $event_type The event that triggered the webhook event notification, such as 'CHECKOUT.ORDER.APPROVED'.
* @param string $summary A summary description for the event notification.
* @param string $resource_version The resource version in the webhook notification, such as '1.0'.
* @param stdClass $resource The resource that triggered the webhook event notification.
*/
public function __construct( string $id, ?DateTime $create_time, string $resource_type, string $event_version, string $event_type, string $summary, string $resource_version, stdClass $resource ) {
$this->id = $id;
$this->create_time = $create_time;
$this->resource_type = $resource_type;
$this->event_version = $event_version;
$this->event_type = $event_type;
$this->summary = $summary;
$this->resource_version = $resource_version;
$this->resource = $resource;
}
/**
* The ID of the event notification.
*
* @return string
*/
public function id(): string {
return $this->id;
}
/**
* The date and time when the event notification was created.
*
* @return DateTime|null
*/
public function create_time(): ?DateTime {
return $this->create_time;
}
/**
* The name of the resource related to the webhook notification event, such as 'checkout-order'.
*
* @return string
*/
public function resource_type(): string {
return $this->resource_type;
}
/**
* The event version in the webhook notification, such as '1.0'.
*
* @return string
*/
public function event_version(): string {
return $this->event_version;
}
/**
* The event that triggered the webhook event notification, such as 'CHECKOUT.ORDER.APPROVED'.
*
* @return string
*/
public function event_type(): string {
return $this->event_type;
}
/**
* A summary description for the event notification.
*
* @return string
*/
public function summary(): string {
return $this->summary;
}
/**
* The resource version in the webhook notification, such as '1.0'.
*
* @return string
*/
public function resource_version(): string {
return $this->resource_version;
}
/**
* The resource that triggered the webhook event notification.
*
* @return stdClass
*/
public function resource(): stdClass {
return $this->resource;
}
}

View file

@ -0,0 +1,74 @@
<?php
/**
* Creates WebhookEvent.
*
* @package WooCommerce\PayPalCommerce\ApiClient\Factory
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Factory;
use DateTime;
use stdClass;
use WooCommerce\PayPalCommerce\ApiClient\Entity\WebhookEvent;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
/**
* Class WebhookEventFactory
*/
class WebhookEventFactory {
/**
* Returns a webhook from a given data array.
*
* @param array $data The data array.
*
* @return WebhookEvent
*/
public function from_array( array $data ): WebhookEvent {
return $this->from_paypal_response( (object) $data );
}
/**
* Returns a Webhook based of a PayPal JSON response.
*
* @param stdClass $data The JSON object.
*
* @return WebhookEvent
* @throws RuntimeException When JSON object is malformed.
*/
public function from_paypal_response( stdClass $data ): WebhookEvent {
if ( ! isset( $data->id ) ) {
throw new RuntimeException(
__( 'ID for webhook event not found.', 'woocommerce-paypal-payments' )
);
}
if ( ! isset( $data->event_type ) ) {
throw new RuntimeException(
__( 'Event type for webhook event not found.', 'woocommerce-paypal-payments' )
);
}
$create_time = ( isset( $data->create_time ) ) ?
DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $data->create_time )
: null;
// Sometimes the time may be in weird format 2018-12-19T22:20:32.000Z (at least in simulation),
// we do not care much about time, so just ignore on failure.
if ( false === $create_time ) {
$create_time = null;
}
return new WebhookEvent(
(string) $data->id,
$create_time,
(string) $data->resource_type ?? '',
(string) $data->event_version ?? '',
(string) $data->event_type,
(string) $data->summary ?? '',
(string) $data->resource_version ?? '',
(object) $data->resource ?? ( new stdClass() )
);
}
}

View file

@ -43,6 +43,27 @@ return array(
),
);
$is_registered = $container->get( 'webhook.is-registered' );
if ( $is_registered ) {
$status_page_fields = array_merge(
$status_page_fields,
array(
'webhooks_simulate' => array(
'title' => __( 'Webhook simulation', 'woocommerce-paypal-payments' ),
'type' => 'ppcp-text',
'text' => '<button type="button" class="button ppcp-webhooks-simulate">' . esc_html__( 'Simulate', 'woocommerce-paypal-payments' ) . '</button>',
'screens' => array(
State::STATE_PROGRESSIVE,
State::STATE_ONBOARDED,
),
'requirements' => array(),
'gateway' => WebhooksStatusPage::ID,
'description' => __( 'Click to request a sample webhook payload from PayPal, allowing to check that your server can successfully receive webhooks.', 'woocommerce-paypal-payments' ),
),
)
);
}
return array_merge( $fields, $status_page_fields );
},
);

View file

@ -1,9 +1,16 @@
.ppcp-webhooks-table {
.ppcp-webhooks-table, .ppcp-webhooks-status-text {
.error {
color: red;
font-weight: bold;
}
.success {
color: green;
font-weight: bold;
}
}
.ppcp-webhooks-table {
table {
border-collapse: collapse;
@ -21,3 +28,7 @@
}
}
}
.ppcp-webhooks-status-text {
padding-top: 4px;
}

View file

@ -38,5 +38,111 @@ document.addEventListener(
window.location.reload();
});
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const simulateBtn = jQuery(PayPalCommerceGatewayWebhooksStatus.simulation.start.button);
simulateBtn.click(async () => {
simulateBtn.prop('disabled', true);
try {
const response = await fetch(
PayPalCommerceGatewayWebhooksStatus.simulation.start.endpoint,
{
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify(
{
nonce: PayPalCommerceGatewayWebhooksStatus.simulation.start.nonce,
}
)
}
);
const reportError = error => {
const msg = PayPalCommerceGatewayWebhooksStatus.simulation.start.failureMessage + ' ' + error;
alert(msg);
};
if (!response.ok) {
try {
const result = await response.json();
reportError(result.data);
} catch (exc) {
console.error(exc);
reportError(response.status);
}
return;
}
const showStatus = html => {
let statusBlock = simulateBtn.siblings('.ppcp-webhooks-status-text');
if (!statusBlock.length) {
statusBlock = jQuery('<div class="ppcp-webhooks-status-text"></div>').insertAfter(simulateBtn);
}
statusBlock.html(html);
};
simulateBtn.siblings('.description').hide();
showStatus(
PayPalCommerceGatewayWebhooksStatus.simulation.state.waitingMessage +
'<span class="spinner is-active" style="float: none;"></span>'
);
const delay = 2000;
const retriesBeforeErrorMessage = 15;
const maxRetries = 30;
for (let i = 0; i < maxRetries; i++) {
await sleep(delay);
const stateResponse = await fetch(
PayPalCommerceGatewayWebhooksStatus.simulation.state.endpoint,
{
method: 'GET',
}
);
try {
const result = await stateResponse.json();
if (!stateResponse.ok || !result.success) {
console.error('Simulation state query failed: ' + result.data);
continue;
}
const state = result.data.state;
if (state === PayPalCommerceGatewayWebhooksStatus.simulation.state.successState) {
showStatus(
'<span class="success">' +
'✔️ ' +
PayPalCommerceGatewayWebhooksStatus.simulation.state.successMessage +
'</span>'
);
return;
}
} catch (exc) {
console.error(exc);
}
if (i === retriesBeforeErrorMessage) {
showStatus(
'<span class="error">' +
PayPalCommerceGatewayWebhooksStatus.simulation.state.tooLongDelayMessage +
'</span>'
);
}
}
} finally {
simulateBtn.prop('disabled', false);
}
});
}
);

View file

@ -10,16 +10,21 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Webhooks;
use Exception;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\WebhookEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Webhook;
use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookFactory;
use WooCommerce\PayPalCommerce\WcGateway\Assets\WebhooksStatusPageAssets;
use WooCommerce\PayPalCommerce\Webhooks\Endpoint\ResubscribeEndpoint;
use WooCommerce\PayPalCommerce\Webhooks\Endpoint\SimulateEndpoint;
use WooCommerce\PayPalCommerce\Webhooks\Endpoint\SimulationStateEndpoint;
use WooCommerce\PayPalCommerce\Webhooks\Handler\CheckoutOrderApproved;
use WooCommerce\PayPalCommerce\Webhooks\Handler\CheckoutOrderCompleted;
use WooCommerce\PayPalCommerce\Webhooks\Handler\PaymentCaptureCompleted;
use WooCommerce\PayPalCommerce\Webhooks\Handler\PaymentCaptureRefunded;
use WooCommerce\PayPalCommerce\Webhooks\Handler\PaymentCaptureReversed;
use Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\Webhooks\Status\WebhookSimulation;
return array(
@ -37,16 +42,20 @@ return array(
},
'webhook.endpoint.controller' => function( $container ) : IncomingWebhookEndpoint {
$webhook_endpoint = $container->get( 'api.endpoint.webhook' );
$webhook_factory = $container->get( 'api.factory.webhook' );
$webhook = $container->get( 'webhook.current' );
$handler = $container->get( 'webhook.endpoint.handler' );
$logger = $container->get( 'woocommerce.logger.woocommerce' );
$verify_request = ! defined( 'PAYPAL_WEBHOOK_REQUEST_VERIFICATION' ) || PAYPAL_WEBHOOK_REQUEST_VERIFICATION;
$webhook_event_factory = $container->get( 'api.factory.webhook-event' );
$simulation = $container->get( 'webhook.status.simulation' );
return new IncomingWebhookEndpoint(
$webhook_endpoint,
$webhook_factory,
$webhook,
$logger,
$verify_request,
$webhook_event_factory,
$simulation,
... $handler
);
},
@ -63,6 +72,29 @@ return array(
);
},
'webhook.current' => function( $container ) : ?Webhook {
$data = (array) get_option( WebhookRegistrar::KEY, array() );
if ( empty( $data ) ) {
return null;
}
$factory = $container->get( 'api.factory.webhook' );
assert( $factory instanceof WebhookFactory );
try {
return $factory->from_array( $data );
} catch ( Exception $exception ) {
$logger = $container->get( 'woocommerce.logger.woocommerce' );
assert( $logger instanceof LoggerInterface );
$logger->error( 'Failed to parse the stored webhook data: ' . $exception->getMessage() );
return null;
}
},
'webhook.is-registered' => function( $container ) : bool {
return $container->get( 'webhook.current' ) !== null;
},
'webhook.status.registered-webhooks' => function( $container ) : array {
$endpoint = $container->get( 'api.endpoint.webhook' );
assert( $endpoint instanceof WebhookEndpoint );
@ -107,6 +139,16 @@ return array(
);
},
'webhook.status.simulation' => function( $container ) : WebhookSimulation {
$webhook_endpoint = $container->get( 'api.endpoint.webhook' );
$webhook = $container->get( 'webhook.current' );
return new WebhookSimulation(
$webhook_endpoint,
$webhook,
'PAYMENT.AUTHORIZATION.CREATED'
);
},
'webhook.status.assets' => function( $container ) : WebhooksStatusPageAssets {
return new WebhooksStatusPageAssets(
$container->get( 'webhook.module-url' )
@ -123,6 +165,23 @@ return array(
);
},
'webhook.endpoint.simulate' => static function ( $container ) : SimulateEndpoint {
$simulation = $container->get( 'webhook.status.simulation' );
$request_data = $container->get( 'button.request-data' );
return new SimulateEndpoint(
$simulation,
$request_data
);
},
'webhook.endpoint.simulation-state' => static function ( $container ) : SimulationStateEndpoint {
$simulation = $container->get( 'webhook.status.simulation' );
return new SimulationStateEndpoint(
$simulation
);
},
'webhook.module-url' => static function ( $container ): string {
return plugins_url(
'/modules/ppcp-webhooks/',

View file

@ -0,0 +1,77 @@
<?php
/**
* The endpoint for starting webhooks simulation.
*
* @package WooCommerce\PayPalCommerce\Webhooks\Endpoint
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Webhooks\Endpoint;
use Exception;
use WooCommerce\PayPalCommerce\Button\Endpoint\RequestData;
use WooCommerce\PayPalCommerce\Webhooks\Status\WebhookSimulation;
/**
* Class SimulateEndpoint
*/
class SimulateEndpoint {
const ENDPOINT = 'ppc-webhooks-simulate';
/**
* The simulation handler.
*
* @var WebhookSimulation
*/
private $simulation;
/**
* The Request Data helper object.
*
* @var RequestData
*/
private $request_data;
/**
* SimulateEndpoint constructor.
*
* @param WebhookSimulation $simulation The simulation handler.
* @param RequestData $request_data The Request Data helper object.
*/
public function __construct(
WebhookSimulation $simulation,
RequestData $request_data
) {
$this->simulation = $simulation;
$this->request_data = $request_data;
}
/**
* Returns the nonce for the endpoint.
*
* @return string
*/
public static function nonce(): string {
return self::ENDPOINT;
}
/**
* Handles the incoming request.
*/
public function handle_request() {
try {
// Validate nonce.
$this->request_data->read_request( $this->nonce() );
$this->simulation->start();
wp_send_json_success();
return true;
} catch ( Exception $error ) {
wp_send_json_error( $error->getMessage(), 500 );
return false;
}
}
}

View file

@ -0,0 +1,68 @@
<?php
/**
* The endpoint for getting the current webhooks simulation state.
*
* @package WooCommerce\PayPalCommerce\Webhooks\Endpoint
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Webhooks\Endpoint;
use Exception;
use WooCommerce\PayPalCommerce\Button\Endpoint\RequestData;
use WooCommerce\PayPalCommerce\Webhooks\Status\WebhookSimulation;
/**
* Class SimulationStateEndpoint
*/
class SimulationStateEndpoint {
const ENDPOINT = 'ppc-webhooks-simulation-state';
/**
* The simulation handler.
*
* @var WebhookSimulation
*/
private $simulation;
/**
* SimulationStateEndpoint constructor.
*
* @param WebhookSimulation $simulation The simulation handler.
*/
public function __construct(
WebhookSimulation $simulation
) {
$this->simulation = $simulation;
}
/**
* Returns the nonce for the endpoint.
*
* @return string
*/
public static function nonce(): string {
return self::ENDPOINT;
}
/**
* Handles the incoming request.
*/
public function handle_request() {
try {
$state = $this->simulation->get_state();
wp_send_json_success(
array(
'state' => $state,
)
);
return true;
} catch ( Exception $error ) {
wp_send_json_error( $error->getMessage(), 500 );
return false;
}
}
}

View file

@ -12,6 +12,9 @@ namespace WooCommerce\PayPalCommerce\WcGateway\Assets;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\Webhooks\Endpoint\ResubscribeEndpoint;
use WooCommerce\PayPalCommerce\Webhooks\Endpoint\SimulateEndpoint;
use WooCommerce\PayPalCommerce\Webhooks\Endpoint\SimulationStateEndpoint;
use WooCommerce\PayPalCommerce\Webhooks\Status\WebhookSimulation;
/**
* Class WebhooksStatusPageAssets
@ -77,6 +80,21 @@ class WebhooksStatusPageAssets {
'button' => '.ppcp-webhooks-resubscribe',
'failureMessage' => __( 'Operation failed. Check WooCommerce logs for more details.', 'woocommerce-paypal-payments' ),
),
'simulation' => array(
'start' => array(
'endpoint' => home_url( \WC_AJAX::get_endpoint( SimulateEndpoint::ENDPOINT ) ),
'nonce' => wp_create_nonce( SimulateEndpoint::nonce() ),
'button' => '.ppcp-webhooks-simulate',
'failureMessage' => __( 'Operation failed. Check WooCommerce logs for more details.', 'woocommerce-paypal-payments' ),
),
'state' => array(
'endpoint' => home_url( \WC_AJAX::get_endpoint( SimulationStateEndpoint::ENDPOINT ) ),
'successState' => WebhookSimulation::STATE_RECEIVED,
'waitingMessage' => __( 'Waiting for the webhook to arrive...', 'woocommerce-paypal-payments' ),
'successMessage' => __( 'The webhook was received successfully.', 'woocommerce-paypal-payments' ),
'tooLongDelayMessage' => __( 'Looks like the webhook cannot be received. Check that your website is accessible from the internet.', 'woocommerce-paypal-payments' ),
),
),
);
}

View file

@ -0,0 +1,170 @@
<?php
/**
* Handles the webhook simulation.
*
* @package WooCommerce\PayPalCommerce\Webhooks\Status
*/
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Webhooks\Status;
use Exception;
use UnexpectedValueException;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\WebhookEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Webhook;
use WooCommerce\PayPalCommerce\ApiClient\Entity\WebhookEvent;
/**
* Class WebhookSimulation
*/
class WebhookSimulation {
public const STATE_WAITING = 'waiting';
public const STATE_RECEIVED = 'received';
private const OPTION_ID = 'ppcp-webhook-simulation';
/**
* The webhooks endpoint.
*
* @var WebhookEndpoint
*/
private $webhook_endpoint;
/**
* Our registered webhook.
*
* @var Webhook|null
*/
private $webhook;
/**
* The event type that will be simulated, such as CHECKOUT.ORDER.APPROVED.
*
* @var string
*/
private $event_type;
/**
* WebhookSimulation constructor.
*
* @param WebhookEndpoint $webhook_endpoint The webhooks endpoint.
* @param Webhook|null $webhook Our registered webhook.
* @param string $event_type The event type that will be simulated, such as CHECKOUT.ORDER.APPROVED.
*/
public function __construct(
WebhookEndpoint $webhook_endpoint,
Webhook $webhook,
string $event_type
) {
$this->webhook_endpoint = $webhook_endpoint;
$this->webhook = $webhook;
$this->event_type = $event_type;
}
/**
* Starts the simulation by sending request to PayPal and saving the simulation data with STATE_WAITING.
*
* @throws Exception If failed to start simulation.
*/
public function start() {
if ( ! $this->webhook ) {
throw new Exception( 'Webhooks not registered' );
}
$event = $this->webhook_endpoint->simulate( $this->webhook, $this->event_type );
$this->save(
array(
'id' => $event->id(),
'state' => self::STATE_WAITING,
)
);
}
/**
* Returns true if the given event matches the expected simulation event.
*
* @param WebhookEvent $event The webhook event.
* @return bool
*/
public function is_simulation_event( WebhookEvent $event ): bool {
try {
$data = $this->load();
return isset( $data['id'] ) && $event->id() === $data['id'];
} catch ( Exception $exception ) {
return false;
}
}
/**
* Sets the simulation state to STATE_RECEIVED if the given event matches the expected simulation event.
*
* @param WebhookEvent $event The webhook event.
*
* @return bool
* @throws Exception If failed to save new state.
*/
public function receive( WebhookEvent $event ): bool {
if ( ! $this->is_simulation_event( $event ) ) {
return false;
}
$this->set_state( self::STATE_RECEIVED );
return true;
}
/**
* Returns the current simulation state, one of the STATE_ constants.
*
* @return string
* @throws Exception If failed to load state.
*/
public function get_state(): string {
$data = $this->load();
return $data['state'];
}
/**
* Saves the new state.
*
* @param string $state One of the STATE_ constants.
*
* @throws Exception If failed to load state.
*/
private function set_state( string $state ): void {
$data = $this->load();
$data['state'] = $state;
$this->save( $data );
}
/**
* Saves the simulation data.
*
* @param array $data The simulation data.
*/
private function save( array $data ): void {
update_option( self::OPTION_ID, $data );
}
/**
* Returns the current simulation data.
*
* @return array
* @throws UnexpectedValueException If failed to load.
*/
private function load(): array {
$data = get_option( self::OPTION_ID );
if ( ! $data ) {
throw new UnexpectedValueException( 'Webhook simulation data not found.' );
}
return $data;
}
}

View file

@ -9,11 +9,15 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Webhooks;
use phpDocumentor\Reflection\Types\This;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\WebhookEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Webhook;
use WooCommerce\PayPalCommerce\ApiClient\Entity\WebhookEvent;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\WebhookEventFactory;
use WooCommerce\PayPalCommerce\Webhooks\Handler\RequestHandler;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\Webhooks\Status\WebhookSimulation;
/**
* Class IncomingWebhookEndpoint
@ -31,11 +35,11 @@ class IncomingWebhookEndpoint {
private $webhook_endpoint;
/**
* The Webhook Factory.
* Our registered webhook.
*
* @var WebhookFactory
* @var Webhook|null
*/
private $webhook_factory;
private $webhook;
/**
* The Request handlers.
@ -58,28 +62,48 @@ class IncomingWebhookEndpoint {
*/
private $verify_request;
/**
* The webhook event factory.
*
* @var WebhookEventFactory
*/
private $webhook_event_factory;
/**
* The simulation handler.
*
* @var WebhookSimulation
*/
private $simulation;
/**
* IncomingWebhookEndpoint constructor.
*
* @param WebhookEndpoint $webhook_endpoint The webhook endpoint.
* @param WebhookFactory $webhook_factory The webhook factory.
* @param LoggerInterface $logger The logger.
* @param bool $verify_request Whether requests need to be verified or not.
* @param RequestHandler ...$handlers The handlers, which process a request in the end.
* @param WebhookEndpoint $webhook_endpoint The webhook endpoint.
* @param Webhook|null $webhook Our registered webhook.
* @param LoggerInterface $logger The logger.
* @param bool $verify_request Whether requests need to be verified or not.
* @param WebhookEventFactory $webhook_event_factory The webhook event factory.
* @param WebhookSimulation $simulation The simulation handler.
* @param RequestHandler ...$handlers The handlers, which process a request in the end.
*/
public function __construct(
WebhookEndpoint $webhook_endpoint,
WebhookFactory $webhook_factory,
?Webhook $webhook,
LoggerInterface $logger,
bool $verify_request,
WebhookEventFactory $webhook_event_factory,
WebhookSimulation $simulation,
RequestHandler ...$handlers
) {
$this->webhook_endpoint = $webhook_endpoint;
$this->webhook_factory = $webhook_factory;
$this->handlers = $handlers;
$this->logger = $logger;
$this->verify_request = $verify_request;
$this->webhook_endpoint = $webhook_endpoint;
$this->webhook = $webhook;
$this->handlers = $handlers;
$this->logger = $logger;
$this->verify_request = $verify_request;
$this->webhook_event_factory = $webhook_event_factory;
$this->simulation = $simulation;
}
/**
@ -110,35 +134,34 @@ class IncomingWebhookEndpoint {
/**
* Verifies the current request.
*
* @param \WP_REST_Request $request The request.
*
* @return bool
*/
public function verify_request(): bool {
public function verify_request( \WP_REST_Request $request ): bool {
if ( ! $this->verify_request ) {
return true;
}
if ( ! $this->webhook ) {
$this->logger->error( 'Failed to retrieve stored webhook data.' );
return false;
}
try {
$data = (array) get_option( WebhookRegistrar::KEY, array() );
$webhook = $this->webhook_factory->from_array( $data );
$result = $this->webhook_endpoint->verify_current_request_for_webhook( $webhook );
$event = $this->event_from_request( $request );
if ( $this->simulation->is_simulation_event( $event ) ) {
return true;
}
$result = $this->webhook_endpoint->verify_current_request_for_webhook( $this->webhook );
if ( ! $result ) {
$this->logger->log(
'error',
__( 'Illegit Webhook request detected.', 'woocommerce-paypal-payments' )
);
$this->logger->error( 'Webhook verification failed.' );
}
return $result;
} catch ( RuntimeException $exception ) {
$this->logger->log(
'error',
sprintf(
// translators: %s is the error message.
__(
'Illegit Webhook request detected: %s',
'woocommerce-paypal-payments'
),
$exception->getMessage()
)
);
$this->logger->error( 'Webhook verification failed: ' . $exception->getMessage() );
return false;
}
}
@ -151,6 +174,17 @@ class IncomingWebhookEndpoint {
* @return \WP_REST_Response
*/
public function handle_request( \WP_REST_Request $request ): \WP_REST_Response {
$event = $this->event_from_request( $request );
if ( $this->simulation->is_simulation_event( $event ) ) {
$this->logger->info( 'Received simulated webhook.' );
$this->simulation->receive( $event );
return rest_ensure_response(
array(
'success' => true,
)
);
}
foreach ( $this->handlers as $handler ) {
if ( $handler->responsible_for_request( $request ) ) {
@ -211,4 +245,16 @@ class IncomingWebhookEndpoint {
}
return array_unique( $event_types );
}
/**
* Creates WebhookEvent from request data.
*
* @param \WP_REST_Request $request The request with event data.
*
* @return WebhookEvent
* @throws RuntimeException When failed to create.
*/
private function event_from_request( \WP_REST_Request $request ): WebhookEvent {
return $this->webhook_event_factory->from_array( $request->get_params() );
}
}

View file

@ -17,6 +17,8 @@ use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\WcGateway\Assets\WebhooksStatusPageAssets;
use WooCommerce\PayPalCommerce\Webhooks\Endpoint\ResubscribeEndpoint;
use WooCommerce\PayPalCommerce\Webhooks\Endpoint\SimulateEndpoint;
use WooCommerce\PayPalCommerce\Webhooks\Endpoint\SimulationStateEndpoint;
use WooCommerce\PayPalCommerce\Webhooks\Status\WebhooksStatusPage;
/**
@ -94,6 +96,25 @@ class WebhookModule implements ModuleInterface {
}
);
add_action(
'wc_ajax_' . SimulateEndpoint::ENDPOINT,
static function () use ( $container ) {
$endpoint = $container->get( 'webhook.endpoint.simulate' );
assert( $endpoint instanceof SimulateEndpoint );
$endpoint->handle_request();
}
);
add_action(
'wc_ajax_' . SimulationStateEndpoint::ENDPOINT,
static function () use ( $container ) {
$endpoint = $container->get( 'webhook.endpoint.simulation-state' );
assert( $endpoint instanceof SimulationStateEndpoint );
$endpoint->handle_request();
}
);
$page_id = $container->get( 'wcgateway.current-ppcp-settings-page-id' );
if ( WebhooksStatusPage::ID === $page_id ) {
$GLOBALS['hide_save_button'] = true;