From f7a2f39cd5ca71220cee1b78689ebff1f8bbee78 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Wed, 19 Aug 2026 11:33:15 +0530 Subject: [PATCH 1/5] fix: validate Stripe price ID before checkout --- inc/render/class-stripe-checkout-block.php | 109 ++++++++++- tests/test-stripe-checkout-block.php | 200 +++++++++++++++++++++ 2 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 tests/test-stripe-checkout-block.php diff --git a/inc/render/class-stripe-checkout-block.php b/inc/render/class-stripe-checkout-block.php index 64efbf819..924da835c 100644 --- a/inc/render/class-stripe-checkout-block.php +++ b/inc/render/class-stripe-checkout-block.php @@ -57,7 +57,6 @@ 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 if ( empty( $product_id ) || empty( $price_id ) || empty( $url ) ) { return sprintf( @@ -66,12 +65,21 @@ public function watch_checkout() { ); } + $post_id = $this->get_post_id_for_checkout( $product_id, $price_id, $url ); + + if ( 0 === $post_id ) { + 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 + get_permalink( $post_id ) ); $session = $this->stripe_api->create_request( @@ -85,7 +93,7 @@ public function watch_checkout() { 'quantity' => 1, ), ), - 'mode' => $mode, + 'mode' => $this->get_mode_for_price( $price_id ), ) ); @@ -165,15 +173,12 @@ public function render( $attributes ) { $details_markup .= '
' . $currency . $amount . '
'; $details_markup .= ''; - $mode = 'recurring' === $price['type'] ? 'subscription' : 'payment'; - $session_url = add_query_arg( array( 'action' => 'buy_stripe', 'product_id' => $attributes['product'], 'price_id' => $attributes['price'], 'url' => get_permalink(), - 'mode' => $mode, ), get_permalink() ); @@ -188,6 +193,98 @@ public function render( $attributes ) { ); } + /** + * Get the ID of the post that offers the given product/price pair. + * + * @param string $product_id Stripe product ID. + * @param string $price_id Stripe price ID. + * @param string $url URL of the page holding the checkout block. + * @return int Post ID, or 0 when the pair is not offered there. + */ + private function get_post_id_for_checkout( $product_id, $price_id, $url ) { + if ( function_exists( 'wpcom_vip_url_to_postid' ) ) { + $post_id = wpcom_vip_url_to_postid( $url ); + } else { + $post_id = url_to_postid( $url ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.url_to_postid_url_to_postid + } + + if ( 0 === $post_id ) { + return 0; + } + + $post = get_post( $post_id ); + + if ( ! $post instanceof \WP_Post ) { + return 0; + } + + if ( 'publish' !== $post->post_status && ! current_user_can( 'read_post', $post_id ) ) { + return 0; + } + + return $this->blocks_have_checkout( parse_blocks( $post->post_content ), $product_id, $price_id ) ? $post_id : 0; + } + + /** + * Check whether a block tree contains a Stripe Checkout block for the given product/price pair. + * + * @param array> $blocks Parsed blocks. + * @param string $product_id Stripe product ID. + * @param string $price_id Stripe price ID. + * @param int $depth Current recursion depth. + * @return bool + */ + private function blocks_have_checkout( $blocks, $product_id, $price_id, $depth = 0 ) { + if ( 10 < $depth ) { + return false; + } + + foreach ( $blocks as $block ) { + if ( ! isset( $block['blockName'] ) ) { + continue; + } + + if ( 'themeisle-blocks/stripe-checkout' === $block['blockName'] ) { + $attrs = isset( $block['attrs'] ) ? $block['attrs'] : array(); + + if ( isset( $attrs['product'], $attrs['price'] ) && $product_id === $attrs['product'] && $price_id === $attrs['price'] ) { + return true; + } + } + + // Synced patterns keep their content in a separate post. + if ( 'core/block' === $block['blockName'] && isset( $block['attrs']['ref'] ) ) { + $reusable = get_post( (int) $block['attrs']['ref'] ); + + if ( $reusable instanceof \WP_Post && $this->blocks_have_checkout( parse_blocks( $reusable->post_content ), $product_id, $price_id, $depth + 1 ) ) { + return true; + } + } + + if ( ! empty( $block['innerBlocks'] ) && $this->blocks_have_checkout( $block['innerBlocks'], $product_id, $price_id, $depth + 1 ) ) { + return true; + } + } + + return false; + } + + /** + * 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 ) { + $price = $this->stripe_api->create_request( 'price', $price_id ); + + if ( is_wp_error( $price ) || ! isset( $price['type'] ) ) { + return 'payment'; + } + + return 'recurring' === $price['type'] ? 'subscription' : 'payment'; + } + /** * Format the error message. * diff --git a/tests/test-stripe-checkout-block.php b/tests/test-stripe-checkout-block.php new file mode 100644 index 000000000..dd5a062a0 --- /dev/null +++ b/tests/test-stripe-checkout-block.php @@ -0,0 +1,200 @@ +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'] ); + + 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 ); + } + + /** + * 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 price configured in the block on the page creates a session. + */ + public function test_configured_price_creates_session() { + $location = $this->run_checkout( + array( + 'product_id' => 'prod_1', + 'price_id' => 'price_1', + 'url' => get_permalink( $this->post_id ), + ) + ); + + $this->assertStringContainsString( 'checkout.stripe.com', $location ); + } + + /** + * A price that is not configured in the block is rejected. + */ + public function test_arbitrary_price_is_rejected() { + $location = $this->run_checkout( + array( + 'product_id' => 'prod_1', + 'price_id' => 'price_cheap', + 'url' => get_permalink( $this->post_id ), + ) + ); + + $this->assertSame( '', $location ); + } + + /** + * A product that is not configured in the block is rejected. + */ + public function test_arbitrary_product_is_rejected() { + $location = $this->run_checkout( + array( + 'product_id' => 'prod_2', + 'price_id' => 'price_1', + 'url' => get_permalink( $this->post_id ), + ) + ); + + $this->assertSame( '', $location ); + } + + /** + * A page without a checkout block for the pair is rejected. + */ + public function test_unrelated_page_is_rejected() { + $other_id = $this->factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'Nothing to buy here.', + ) + ); + + $location = $this->run_checkout( + array( + 'product_id' => 'prod_1', + 'price_id' => 'price_1', + 'url' => get_permalink( $other_id ), + ) + ); + + $this->assertSame( '', $location ); + } + + /** + * A draft page is not a valid checkout source for visitors. + */ + public function test_draft_page_is_rejected_for_visitors() { + wp_update_post( + array( + 'ID' => $this->post_id, + 'post_status' => 'draft', + ) + ); + + $location = $this->run_checkout( + array( + 'product_id' => 'prod_1', + 'price_id' => 'price_1', + 'url' => add_query_arg( 'p', $this->post_id, home_url( '/' ) ), + ) + ); + + $this->assertSame( '', $location ); + } + + /** + * A block nested inside a container is still a valid checkout source. + */ + public function test_nested_block_is_accepted() { + $nested_id = $this->factory()->post->create( + array( + 'post_status' => 'publish', + 'post_content' => '
', + ) + ); + + $location = $this->run_checkout( + array( + 'product_id' => 'prod_1', + 'price_id' => 'price_1', + 'url' => get_permalink( $nested_id ), + ) + ); + + $this->assertStringContainsString( 'checkout.stripe.com', $location ); + } +} From 0e68fcb51725fc292c2cf692be29dcd427190cde Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Wed, 19 Aug 2026 12:13:26 +0530 Subject: [PATCH 2/5] fix: improve block traversal to prevent reference cycles --- inc/render/class-stripe-checkout-block.php | 29 ++++++++++++---------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/inc/render/class-stripe-checkout-block.php b/inc/render/class-stripe-checkout-block.php index 924da835c..95ebeba1b 100644 --- a/inc/render/class-stripe-checkout-block.php +++ b/inc/render/class-stripe-checkout-block.php @@ -56,7 +56,7 @@ 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 + $url = isset( $_GET['url'] ) ? sanitize_url( wp_unslash( $_GET['url'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( empty( $product_id ) || empty( $price_id ) || empty( $url ) ) { return sprintf( @@ -222,7 +222,9 @@ private function get_post_id_for_checkout( $product_id, $price_id, $url ) { return 0; } - return $this->blocks_have_checkout( parse_blocks( $post->post_content ), $product_id, $price_id ) ? $post_id : 0; + $visited = array( $post_id => true ); + + return $this->blocks_have_checkout( parse_blocks( $post->post_content ), $product_id, $price_id, $visited ) ? $post_id : 0; } /** @@ -231,14 +233,10 @@ private function get_post_id_for_checkout( $product_id, $price_id, $url ) { * @param array> $blocks Parsed blocks. * @param string $product_id Stripe product ID. * @param string $price_id Stripe price ID. - * @param int $depth Current recursion depth. + * @param array $visited Reusable block IDs already traversed. * @return bool */ - private function blocks_have_checkout( $blocks, $product_id, $price_id, $depth = 0 ) { - if ( 10 < $depth ) { - return false; - } - + private function blocks_have_checkout( $blocks, $product_id, $price_id, &$visited = array() ) { foreach ( $blocks as $block ) { if ( ! isset( $block['blockName'] ) ) { continue; @@ -252,16 +250,21 @@ private function blocks_have_checkout( $blocks, $product_id, $price_id, $depth = } } - // Synced patterns keep their content in a separate post. + // Synced patterns keep their content in a separate post; guard against reference cycles. if ( 'core/block' === $block['blockName'] && isset( $block['attrs']['ref'] ) ) { - $reusable = get_post( (int) $block['attrs']['ref'] ); + $ref = (int) $block['attrs']['ref']; - if ( $reusable instanceof \WP_Post && $this->blocks_have_checkout( parse_blocks( $reusable->post_content ), $product_id, $price_id, $depth + 1 ) ) { - return true; + if ( ! isset( $visited[ $ref ] ) ) { + $visited[ $ref ] = true; + $reusable = get_post( $ref ); + + if ( $reusable instanceof \WP_Post && $this->blocks_have_checkout( parse_blocks( $reusable->post_content ), $product_id, $price_id, $visited ) ) { + return true; + } } } - if ( ! empty( $block['innerBlocks'] ) && $this->blocks_have_checkout( $block['innerBlocks'], $product_id, $price_id, $depth + 1 ) ) { + if ( ! empty( $block['innerBlocks'] ) && $this->blocks_have_checkout( $block['innerBlocks'], $product_id, $price_id, $visited ) ) { return true; } } From 9dc22740ed9519c8b378c2619336a47651ce66d4 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Wed, 19 Aug 2026 12:55:01 +0530 Subject: [PATCH 3/5] fix: implement caching for Stripe price mode --- inc/render/class-stripe-checkout-block.php | 20 +++++++++- tests/stripe-http-client-mock.php | 14 +++++++ tests/test-stripe-checkout-block.php | 43 ++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/inc/render/class-stripe-checkout-block.php b/inc/render/class-stripe-checkout-block.php index 95ebeba1b..84bd1edbf 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. * @@ -279,13 +286,24 @@ private function blocks_have_checkout( $blocks, $product_id, $price_id, &$visite * @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'; } - return 'recurring' === $price['type'] ? 'subscription' : 'payment'; + $mode = 'recurring' === $price['type'] ? 'subscription' : 'payment'; + + set_transient( $cache_key, $mode, WEEK_IN_SECONDS ); + + return $mode; } /** diff --git a/tests/stripe-http-client-mock.php b/tests/stripe-http-client-mock.php index 1f170fd90..95ddc3a14 100644 --- a/tests/stripe-http-client-mock.php +++ b/tests/stripe-http-client-mock.php @@ -11,11 +11,25 @@ class StripeHttpClientMock implements ClientInterface { + /** + * Paths requested since the last reset, so tests can assert on API usage. + * + * @var array + */ + public static $request_paths = array(); + + public static function reset_request_paths() + { + self::$request_paths = array(); + } + public function request($method, $absUrl, $headers, $params, $hasFile) { $urlParts = parse_url($absUrl); $path = $urlParts['path']; + self::$request_paths[] = $path; + if ($path === '/v1/products') { return array($this->mockProductsList(), 200, null); } diff --git a/tests/test-stripe-checkout-block.php b/tests/test-stripe-checkout-block.php index dd5a062a0..9cba6d8dd 100644 --- a/tests/test-stripe-checkout-block.php +++ b/tests/test-stripe-checkout-block.php @@ -27,12 +27,21 @@ class TestStripeCheckoutBlock extends WP_UnitTestCase { */ private $post_id; + /** + * Value of the Stripe API key option before the test. + * + * @var mixed + */ + private $previous_api_key; + /** * Set up the test. */ public function set_up() { parent::set_up(); + $this->previous_api_key = get_option( 'themeisle_stripe_api_key' ); + update_option( 'themeisle_stripe_api_key', 'sk_test' ); \Stripe\ApiRequestor::setHttpClient( new StripeHttpClientMock() ); @@ -56,6 +65,17 @@ public function tear_down() { remove_filter( 'wp_redirect', array( $this, 'throw_on_redirect' ) ); unset( $_GET['action'], $_GET['product_id'], $_GET['price_id'], $_GET['url'] ); + 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(); } @@ -102,6 +122,29 @@ public function test_configured_price_creates_session() { $this->assertStringContainsString( 'checkout.stripe.com', $location ); } + /** + * The checkout mode is derived from the price and then served from cache. + */ + public function test_price_mode_is_cached() { + $args = array( + 'product_id' => 'prod_1', + 'price_id' => 'price_1', + 'url' => get_permalink( $this->post_id ), + ); + + StripeHttpClientMock::reset_request_paths(); + $this->run_checkout( $args ); + + $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( $args ); + + $this->assertNotContains( '/v1/prices/price_1', StripeHttpClientMock::$request_paths ); + $this->assertStringContainsString( 'checkout.stripe.com', $location ); + } + /** * A price that is not configured in the block is rejected. */ From ac0fc732d351ebb6c460dc9bca98c8244bd014d0 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Thu, 20 Aug 2026 10:38:11 +0530 Subject: [PATCH 4/5] fix: enhance Stripe Checkout block with token validation --- inc/render/class-stripe-checkout-block.php | 96 +++-------- tests/stripe-http-client-mock.php | 19 +++ tests/test-stripe-checkout-block.php | 179 +++++++++++---------- 3 files changed, 143 insertions(+), 151 deletions(-) diff --git a/inc/render/class-stripe-checkout-block.php b/inc/render/class-stripe-checkout-block.php index 84bd1edbf..b456ccb72 100644 --- a/inc/render/class-stripe-checkout-block.php +++ b/inc/render/class-stripe-checkout-block.php @@ -64,6 +64,7 @@ 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_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( @@ -72,9 +73,7 @@ public function watch_checkout() { ); } - $post_id = $this->get_post_id_for_checkout( $product_id, $price_id, $url ); - - if ( 0 === $post_id ) { + 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' ) @@ -86,7 +85,7 @@ public function watch_checkout() { 'stripe_session_id' => '{CHECKOUT_SESSION_ID}', 'product_id' => $product_id, ), - get_permalink( $post_id ) + $this->get_return_url( $url ) ); $session = $this->stripe_api->create_request( @@ -180,14 +179,22 @@ public function render( $attributes ) { $details_markup .= '
' . $currency . $amount . '
'; $details_markup .= ''; + // 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(), + 'url' => $return_url, + 'token' => self::get_checkout_token( $attributes['product'], $attributes['price'] ), ), - get_permalink() + $return_url ); $button_markup = '' . __( 'Checkout', 'otter-blocks' ) . ''; @@ -201,82 +208,31 @@ public function render( $attributes ) { } /** - * Get the ID of the post that offers the given product/price pair. + * 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. - * @param string $url URL of the page holding the checkout block. - * @return int Post ID, or 0 when the pair is not offered there. + * @return string */ - private function get_post_id_for_checkout( $product_id, $price_id, $url ) { - if ( function_exists( 'wpcom_vip_url_to_postid' ) ) { - $post_id = wpcom_vip_url_to_postid( $url ); - } else { - $post_id = url_to_postid( $url ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.url_to_postid_url_to_postid - } - - if ( 0 === $post_id ) { - return 0; - } - - $post = get_post( $post_id ); - - if ( ! $post instanceof \WP_Post ) { - return 0; - } - - if ( 'publish' !== $post->post_status && ! current_user_can( 'read_post', $post_id ) ) { - return 0; - } - - $visited = array( $post_id => true ); - - return $this->blocks_have_checkout( parse_blocks( $post->post_content ), $product_id, $price_id, $visited ) ? $post_id : 0; + public static function get_checkout_token( $product_id, $price_id ) { + return hash_hmac( 'sha256', $product_id . '|' . $price_id, wp_salt( 'otter_stripe' ) ); } /** - * Check whether a block tree contains a Stripe Checkout block for the given product/price pair. + * Get the URL Stripe returns the buyer to, restricted to this site. * - * @param array> $blocks Parsed blocks. - * @param string $product_id Stripe product ID. - * @param string $price_id Stripe price ID. - * @param array $visited Reusable block IDs already traversed. - * @return bool + * @param string $url Requested return URL. + * @return string */ - private function blocks_have_checkout( $blocks, $product_id, $price_id, &$visited = array() ) { - foreach ( $blocks as $block ) { - if ( ! isset( $block['blockName'] ) ) { - continue; - } - - if ( 'themeisle-blocks/stripe-checkout' === $block['blockName'] ) { - $attrs = isset( $block['attrs'] ) ? $block['attrs'] : array(); - - if ( isset( $attrs['product'], $attrs['price'] ) && $product_id === $attrs['product'] && $price_id === $attrs['price'] ) { - return true; - } - } + private function get_return_url( $url ) { + $host = wp_parse_url( $url, PHP_URL_HOST ); + $home = wp_parse_url( home_url(), PHP_URL_HOST ); - // Synced patterns keep their content in a separate post; guard against reference cycles. - if ( 'core/block' === $block['blockName'] && isset( $block['attrs']['ref'] ) ) { - $ref = (int) $block['attrs']['ref']; - - if ( ! isset( $visited[ $ref ] ) ) { - $visited[ $ref ] = true; - $reusable = get_post( $ref ); - - if ( $reusable instanceof \WP_Post && $this->blocks_have_checkout( parse_blocks( $reusable->post_content ), $product_id, $price_id, $visited ) ) { - return true; - } - } - } - - if ( ! empty( $block['innerBlocks'] ) && $this->blocks_have_checkout( $block['innerBlocks'], $product_id, $price_id, $visited ) ) { - return true; - } + if ( $home !== $host ) { + return home_url( '/' ); } - return false; + return $url; } /** diff --git a/tests/stripe-http-client-mock.php b/tests/stripe-http-client-mock.php index 95ddc3a14..639692503 100644 --- a/tests/stripe-http-client-mock.php +++ b/tests/stripe-http-client-mock.php @@ -18,17 +18,35 @@ class StripeHttpClientMock implements ClientInterface */ 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); @@ -115,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 index 9cba6d8dd..36f7efea4 100644 --- a/tests/test-stripe-checkout-block.php +++ b/tests/test-stripe-checkout-block.php @@ -63,7 +63,7 @@ public function set_up() { */ public function tear_down() { remove_filter( 'wp_redirect', array( $this, 'throw_on_redirect' ) ); - unset( $_GET['action'], $_GET['product_id'], $_GET['price_id'], $_GET['url'] ); + 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' ) ); @@ -89,6 +89,24 @@ 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. * @@ -108,111 +126,98 @@ private function run_checkout( $params ) { } /** - * A price configured in the block on the page creates a session. + * A pair signed by a rendered block creates a session. */ - public function test_configured_price_creates_session() { - $location = $this->run_checkout( - array( - 'product_id' => 'prod_1', - 'price_id' => 'price_1', - 'url' => get_permalink( $this->post_id ), - ) - ); + public function test_signed_pair_creates_session() { + $location = $this->run_checkout( $this->checkout_params() ); $this->assertStringContainsString( 'checkout.stripe.com', $location ); } /** - * The checkout mode is derived from the price and then served from cache. + * The token emitted by the rendered block is accepted as-is. */ - public function test_price_mode_is_cached() { - $args = array( - 'product_id' => 'prod_1', - 'price_id' => 'price_1', - 'url' => get_permalink( $this->post_id ), + public function test_token_from_rendered_block_is_accepted() { + $markup = $this->block->render( + array( + 'product' => 'prod_1', + 'price' => 'price_1', + ) ); - StripeHttpClientMock::reset_request_paths(); - $this->run_checkout( $args ); + preg_match( '/token=([a-f0-9]+)/', html_entity_decode( $markup ), $matches ); - $this->assertContains( '/v1/prices/price_1', StripeHttpClientMock::$request_paths ); - $this->assertSame( 'payment', get_transient( Stripe_Checkout_Block::PRICE_MODE_CACHE_PREFIX . md5( 'price_1' ) ) ); + $this->assertNotEmpty( $matches, 'The buy link should carry a token.' ); - StripeHttpClientMock::reset_request_paths(); - $location = $this->run_checkout( $args ); + $location = $this->run_checkout( $this->checkout_params( array( 'token' => $matches[1] ) ) ); - $this->assertNotContains( '/v1/prices/price_1', StripeHttpClientMock::$request_paths ); $this->assertStringContainsString( 'checkout.stripe.com', $location ); } /** - * A price that is not configured in the block is rejected. + * A block outside post content still works, because the token does not depend on placement. */ - public function test_arbitrary_price_is_rejected() { - $location = $this->run_checkout( + public function test_widget_placement_is_accepted() { + $widget_only_id = $this->factory()->post->create( array( - 'product_id' => 'prod_1', - 'price_id' => 'price_cheap', - 'url' => get_permalink( $this->post_id ), + 'post_status' => 'publish', + 'post_content' => 'The checkout block lives in a widget area, not here.', ) ); - $this->assertSame( '', $location ); + $location = $this->run_checkout( $this->checkout_params( array( 'url' => get_permalink( $widget_only_id ) ) ) ); + + $this->assertStringContainsString( 'checkout.stripe.com', $location ); } /** - * A product that is not configured in the block is rejected. + * A non-singular URL that resolves to no post is still a valid return URL. */ - public function test_arbitrary_product_is_rejected() { - $location = $this->run_checkout( - array( - 'product_id' => 'prod_2', - 'price_id' => 'price_1', - 'url' => get_permalink( $this->post_id ), - ) - ); + 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 page without a checkout block for the pair is rejected. + * A price swapped in while keeping the token of another pair is rejected. */ - public function test_unrelated_page_is_rejected() { - $other_id = $this->factory()->post->create( - array( - 'post_status' => 'publish', - 'post_content' => 'Nothing to buy here.', - ) - ); + public function test_substituted_price_is_rejected() { + $location = $this->run_checkout( $this->checkout_params( array( 'price_id' => 'price_cheap' ) ) ); - $location = $this->run_checkout( - array( - 'product_id' => 'prod_1', - 'price_id' => 'price_1', - 'url' => get_permalink( $other_id ), - ) - ); + $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 draft page is not a valid checkout source for visitors. + * A token that was not derived from the site salt is rejected. */ - public function test_draft_page_is_rejected_for_visitors() { - wp_update_post( - array( - 'ID' => $this->post_id, - 'post_status' => 'draft', - ) - ); + public function test_forged_token_is_rejected() { + $forged = hash_hmac( 'sha256', 'prod_1|price_cheap', 'guessed-salt' ); $location = $this->run_checkout( - array( - 'product_id' => 'prod_1', - 'price_id' => 'price_1', - 'url' => add_query_arg( 'p', $this->post_id, home_url( '/' ) ), + $this->checkout_params( + array( + 'price_id' => 'price_cheap', + 'token' => $forged, + ) ) ); @@ -220,24 +225,36 @@ public function test_draft_page_is_rejected_for_visitors() { } /** - * A block nested inside a container is still a valid checkout source. + * An off-site return URL is replaced with this site's home URL. */ - public function test_nested_block_is_accepted() { - $nested_id = $this->factory()->post->create( - array( - 'post_status' => 'publish', - 'post_content' => '
', - ) - ); + public function test_offsite_return_url_is_replaced() { + StripeHttpClientMock::reset_request_params(); - $location = $this->run_checkout( - array( - 'product_id' => 'prod_1', - 'price_id' => 'price_1', - 'url' => get_permalink( $nested_id ), - ) - ); + $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 ); } } From 4533331992d177992e9f0206c8f06e6e36676120 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Thu, 20 Aug 2026 11:32:59 +0530 Subject: [PATCH 5/5] fix: e2e test --- src/blocks/test/e2e/blocks/tabs.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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' }); });