diff --git a/inc/render/class-stripe-checkout-block.php b/inc/render/class-stripe-checkout-block.php index 64efbf819..b456ccb72 100644 --- a/inc/render/class-stripe-checkout-block.php +++ b/inc/render/class-stripe-checkout-block.php @@ -15,6 +15,13 @@ * Class Stripe_Checkout_Block */ class Stripe_Checkout_Block { + /** + * Transient prefix for the cached checkout mode of a price. + * + * @var string + */ + const PRICE_MODE_CACHE_PREFIX = 'otter_stripe_price_mode_'; + /** * Stripe API instance. * @@ -56,8 +63,8 @@ public function watch_checkout() { $product_id = isset( $_GET['product_id'] ) ? sanitize_text_field( wp_unslash( $_GET['product_id'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $price_id = isset( $_GET['price_id'] ) ? sanitize_text_field( wp_unslash( $_GET['price_id'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended - $url = isset( $_GET['url'] ) ? sanitize_text_field( wp_unslash( $_GET['url'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended - $mode = isset( $_GET['mode'] ) ? sanitize_text_field( wp_unslash( $_GET['mode'] ) ) : 'payment'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $url = isset( $_GET['url'] ) ? sanitize_url( wp_unslash( $_GET['url'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( empty( $product_id ) || empty( $price_id ) || empty( $url ) ) { return sprintf( @@ -66,12 +73,19 @@ public function watch_checkout() { ); } + if ( ! hash_equals( self::get_checkout_token( $product_id, $price_id ), $token ) ) { + return sprintf( + '
%s
', + __( 'An error occurred! Could not retrieve the product information!', 'otter-blocks' ) + ); + } + $permalink = add_query_arg( array( 'stripe_session_id' => '{CHECKOUT_SESSION_ID}', 'product_id' => $product_id, ), - $url + $this->get_return_url( $url ) ); $session = $this->stripe_api->create_request( @@ -85,7 +99,7 @@ public function watch_checkout() { 'quantity' => 1, ), ), - 'mode' => $mode, + 'mode' => $this->get_mode_for_price( $price_id ), ) ); @@ -165,17 +179,22 @@ public function render( $attributes ) { $details_markup .= '
' . $currency . $amount . '
'; $details_markup .= ''; - $mode = 'recurring' === $price['type'] ? 'subscription' : 'payment'; + // A widget area or an FSE template has no permalink of its own, so fall back to the current URL. + $return_url = get_permalink(); + + if ( ! is_string( $return_url ) || '' === $return_url ) { + $return_url = home_url( add_query_arg( array() ) ); + } $session_url = add_query_arg( array( 'action' => 'buy_stripe', 'product_id' => $attributes['product'], 'price_id' => $attributes['price'], - 'url' => get_permalink(), - 'mode' => $mode, + 'url' => $return_url, + 'token' => self::get_checkout_token( $attributes['product'], $attributes['price'] ), ), - get_permalink() + $return_url ); $button_markup = '' . __( 'Checkout', 'otter-blocks' ) . ''; @@ -188,6 +207,61 @@ public function render( $attributes ) { ); } + /** + * Sign a product/price pair so the checkout can verify it was offered by a block. + * + * @param string $product_id Stripe product ID. + * @param string $price_id Stripe price ID. + * @return string + */ + public static function get_checkout_token( $product_id, $price_id ) { + return hash_hmac( 'sha256', $product_id . '|' . $price_id, wp_salt( 'otter_stripe' ) ); + } + + /** + * Get the URL Stripe returns the buyer to, restricted to this site. + * + * @param string $url Requested return URL. + * @return string + */ + private function get_return_url( $url ) { + $host = wp_parse_url( $url, PHP_URL_HOST ); + $home = wp_parse_url( home_url(), PHP_URL_HOST ); + + if ( $home !== $host ) { + return home_url( '/' ); + } + + return $url; + } + + /** + * Get the checkout session mode for a price. + * + * @param string $price_id Stripe price ID. + * @return string + */ + private function get_mode_for_price( $price_id ) { + $cache_key = self::PRICE_MODE_CACHE_PREFIX . md5( $price_id ); + $cached = get_transient( $cache_key ); + + if ( 'payment' === $cached || 'subscription' === $cached ) { + return $cached; + } + + $price = $this->stripe_api->create_request( 'price', $price_id ); + + if ( is_wp_error( $price ) || ! isset( $price['type'] ) ) { + return 'payment'; + } + + $mode = 'recurring' === $price['type'] ? 'subscription' : 'payment'; + + set_transient( $cache_key, $mode, WEEK_IN_SECONDS ); + + return $mode; + } + /** * Format the error message. * diff --git a/src/blocks/test/e2e/blocks/tabs.spec.js b/src/blocks/test/e2e/blocks/tabs.spec.js index ff339c946..207b10eaa 100644 --- a/src/blocks/test/e2e/blocks/tabs.spec.js +++ b/src/blocks/test/e2e/blocks/tabs.spec.js @@ -13,13 +13,13 @@ test.describe( 'Tabs Block', () => { await admin.createNewPost(); }); - test( 'can be created by typing "/tabs"', async({ editor, page }) => { + test( 'can be created by typing "/themeisle-tabs"', async({ editor, page }) => { // Create a Progress Block with the slash block shortcut. await insertBlockBySlash({ editor, page, - shortcut: '/tabs', + shortcut: '/themeisle-tabs', blockName: 'themeisle-blocks/tabs' }); }); diff --git a/tests/stripe-http-client-mock.php b/tests/stripe-http-client-mock.php index 1f170fd90..639692503 100644 --- a/tests/stripe-http-client-mock.php +++ b/tests/stripe-http-client-mock.php @@ -11,11 +11,43 @@ class StripeHttpClientMock implements ClientInterface { + /** + * Paths requested since the last reset, so tests can assert on API usage. + * + * @var array + */ + public static $request_paths = array(); + + /** + * Parameters of the requests made since the last reset, keyed by path. + * + * @var array + */ + public static $request_params = array(); + + public static function reset_request_paths() + { + self::$request_paths = array(); + } + + public static function reset_request_params() + { + self::$request_params = array(); + } + + public static function get_params_for($path) + { + return isset(self::$request_params[$path]) ? self::$request_params[$path] : array(); + } + public function request($method, $absUrl, $headers, $params, $hasFile) { $urlParts = parse_url($absUrl); $path = $urlParts['path']; + self::$request_paths[] = $path; + self::$request_params[$path] = $params; + if ($path === '/v1/products') { return array($this->mockProductsList(), 200, null); } @@ -101,6 +133,7 @@ private function mockSingleProduct() 'price' => 1200, 'currency' => 'USD', 'active' => true, + 'images' => [], 'object' => 'product' ] ); diff --git a/tests/test-stripe-checkout-block.php b/tests/test-stripe-checkout-block.php new file mode 100644 index 000000000..36f7efea4 --- /dev/null +++ b/tests/test-stripe-checkout-block.php @@ -0,0 +1,260 @@ +previous_api_key = get_option( 'themeisle_stripe_api_key' ); + + update_option( 'themeisle_stripe_api_key', 'sk_test' ); + \Stripe\ApiRequestor::setHttpClient( new StripeHttpClientMock() ); + + $this->block = new Stripe_Checkout_Block(); + + $this->post_id = $this->factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => '', + ) + ); + + // wp_safe_redirect() would end the request, so turn the redirect into an exception. + add_filter( 'wp_redirect', array( $this, 'throw_on_redirect' ) ); + } + + /** + * Tear down the test. + */ + public function tear_down() { + remove_filter( 'wp_redirect', array( $this, 'throw_on_redirect' ) ); + unset( $_GET['action'], $_GET['product_id'], $_GET['price_id'], $_GET['url'], $_GET['token'] ); + + delete_transient( Stripe_Checkout_Block::PRICE_MODE_CACHE_PREFIX . md5( 'price_1' ) ); + + if ( false === $this->previous_api_key ) { + delete_option( 'themeisle_stripe_api_key' ); + } else { + update_option( 'themeisle_stripe_api_key', $this->previous_api_key ); + } + + // The library falls back to this client when none is set, so it is the default to restore. + \Stripe\ApiRequestor::setHttpClient( \Stripe\HttpClient\CurlClient::instance() ); + + parent::tear_down(); + } + + /** + * Turn a redirect into an exception carrying the location. + * + * @param string $location Redirect location. + * @throws Exception Always. + */ + public function throw_on_redirect( $location ) { + throw new Exception( $location ); + } + + /** + * Build the request parameters a rendered block would produce, with overrides applied. + * + * @param array $overrides Parameters to override. + * @return array + */ + private function checkout_params( $overrides = array() ) { + return array_merge( + array( + 'product_id' => 'prod_1', + 'price_id' => 'price_1', + 'url' => get_permalink( $this->post_id ), + 'token' => Stripe_Checkout_Block::get_checkout_token( 'prod_1', 'price_1' ), + ), + $overrides + ); + } + + /** + * Run the checkout watcher with the given request parameters. + * + * @param array $params Request parameters. + * @return string Redirect location, or '' when no session was created. + */ + private function run_checkout( $params ) { + $_GET = array_merge( array( 'action' => 'buy_stripe' ), $params ); + + try { + $this->block->watch_checkout(); + } catch ( Exception $e ) { + return $e->getMessage(); + } + + return ''; + } + + /** + * A pair signed by a rendered block creates a session. + */ + public function test_signed_pair_creates_session() { + $location = $this->run_checkout( $this->checkout_params() ); + + $this->assertStringContainsString( 'checkout.stripe.com', $location ); + } + + /** + * The token emitted by the rendered block is accepted as-is. + */ + public function test_token_from_rendered_block_is_accepted() { + $markup = $this->block->render( + array( + 'product' => 'prod_1', + 'price' => 'price_1', + ) + ); + + preg_match( '/token=([a-f0-9]+)/', html_entity_decode( $markup ), $matches ); + + $this->assertNotEmpty( $matches, 'The buy link should carry a token.' ); + + $location = $this->run_checkout( $this->checkout_params( array( 'token' => $matches[1] ) ) ); + + $this->assertStringContainsString( 'checkout.stripe.com', $location ); + } + + /** + * A block outside post content still works, because the token does not depend on placement. + */ + public function test_widget_placement_is_accepted() { + $widget_only_id = $this->factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'The checkout block lives in a widget area, not here.', + ) + ); + + $location = $this->run_checkout( $this->checkout_params( array( 'url' => get_permalink( $widget_only_id ) ) ) ); + + $this->assertStringContainsString( 'checkout.stripe.com', $location ); + } + + /** + * A non-singular URL that resolves to no post is still a valid return URL. + */ + public function test_archive_url_is_accepted() { + $location = $this->run_checkout( $this->checkout_params( array( 'url' => home_url( '/2026/08/' ) ) ) ); + + $this->assertStringContainsString( 'checkout.stripe.com', $location ); + } + + /** + * A request without a token is rejected. + */ + public function test_missing_token_is_rejected() { + $location = $this->run_checkout( $this->checkout_params( array( 'token' => '' ) ) ); + + $this->assertSame( '', $location ); + } + + /** + * A price swapped in while keeping the token of another pair is rejected. + */ + public function test_substituted_price_is_rejected() { + $location = $this->run_checkout( $this->checkout_params( array( 'price_id' => 'price_cheap' ) ) ); + + $this->assertSame( '', $location ); + } + + /** + * A product swapped in while keeping the token of another pair is rejected. + */ + public function test_substituted_product_is_rejected() { + $location = $this->run_checkout( $this->checkout_params( array( 'product_id' => 'prod_2' ) ) ); + + $this->assertSame( '', $location ); + } + + /** + * A token that was not derived from the site salt is rejected. + */ + public function test_forged_token_is_rejected() { + $forged = hash_hmac( 'sha256', 'prod_1|price_cheap', 'guessed-salt' ); + + $location = $this->run_checkout( + $this->checkout_params( + array( + 'price_id' => 'price_cheap', + 'token' => $forged, + ) + ) + ); + + $this->assertSame( '', $location ); + } + + /** + * An off-site return URL is replaced with this site's home URL. + */ + public function test_offsite_return_url_is_replaced() { + StripeHttpClientMock::reset_request_params(); + + $this->run_checkout( $this->checkout_params( array( 'url' => 'https://evil.example.net/collect' ) ) ); + + $params = StripeHttpClientMock::get_params_for( '/v1/checkout/sessions' ); + + $this->assertNotEmpty( $params ); + $this->assertStringStartsWith( home_url(), $params['success_url'] ); + $this->assertStringStartsWith( home_url(), $params['cancel_url'] ); + } + + /** + * The checkout mode is derived from the price and then served from cache. + */ + public function test_price_mode_is_cached() { + $params = $this->checkout_params(); + + StripeHttpClientMock::reset_request_paths(); + $this->run_checkout( $params ); + + $this->assertContains( '/v1/prices/price_1', StripeHttpClientMock::$request_paths ); + $this->assertSame( 'payment', get_transient( Stripe_Checkout_Block::PRICE_MODE_CACHE_PREFIX . md5( 'price_1' ) ) ); + + StripeHttpClientMock::reset_request_paths(); + $location = $this->run_checkout( $params ); + + $this->assertNotContains( '/v1/prices/price_1', StripeHttpClientMock::$request_paths ); + $this->assertStringContainsString( 'checkout.stripe.com', $location ); + } +}