From e7f1f7165afc8e08c27c0e6e5c7b2c61c14b6367 Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Wed, 8 Jul 2026 16:54:50 -0500 Subject: [PATCH 1/8] fix(blocks): fully remove configured-off checkout fields (NPPM-2937) Co-Authored-By: Claude Fable 5 --- .../includes/class-modal-checkout.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/plugins/newspack-blocks/includes/class-modal-checkout.php b/plugins/newspack-blocks/includes/class-modal-checkout.php index fd4fc11df9..095ae4ce06 100644 --- a/plugins/newspack-blocks/includes/class-modal-checkout.php +++ b/plugins/newspack-blocks/includes/class-modal-checkout.php @@ -1332,19 +1332,26 @@ public static function show_admin_bar( $show ) { } /** - * Modify fields for modal checkout. + * Remove the configured-off billing fields from checkout. + * + * Not limited to modal checkout requests (NPPM-2937): the fields must be + * unregistered wherever WooCommerce renders or validates them. When they were + * only removed from the modal render, they stayed registered on the standard + * checkout page, and express checkout wallets (e.g. Apple Pay) would populate + * and fail validation on fields the buyer could not see or edit. * * @param array $fields Checkout fields. * * @return array */ public static function woocommerce_checkout_fields( $fields ) { - if ( ! self::is_modal_checkout() ) { + // My Account checkouts relax required flags instead of removing fields. + if ( method_exists( 'Newspack\WooCommerce_My_Account', 'is_from_my_account' ) && \Newspack\WooCommerce_My_Account::is_from_my_account() ) { return $fields; } $cart = \WC()->cart; - // Don't modify fields if shipping is required. - if ( $cart->needs_shipping_address() ) { + // Don't modify fields if there is no cart or shipping is required. + if ( ! $cart || $cart->needs_shipping_address() ) { return $fields; } /** From e0a029d3f7ce289cb193c6d41b8130b5460ab7fc Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Thu, 9 Jul 2026 14:00:56 -0500 Subject: [PATCH 2/8] test(blocks): cover checkout field removal gating (NPPM-2937) Co-Authored-By: Claude Fable 5 --- .../tests/mocks/newspack-plugin-mocks.php | 21 ++ .../tests/test-modal-checkout.php | 237 +++++++++++++++++- 2 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 plugins/newspack-blocks/tests/mocks/newspack-plugin-mocks.php diff --git a/plugins/newspack-blocks/tests/mocks/newspack-plugin-mocks.php b/plugins/newspack-blocks/tests/mocks/newspack-plugin-mocks.php new file mode 100644 index 0000000000..f86d52b421 --- /dev/null +++ b/plugins/newspack-blocks/tests/mocks/newspack-plugin-mocks.php @@ -0,0 +1,21 @@ +payment_gateways() with no registered gateways. + * + * @return object + */ + public function payment_gateways() { + return new class() { + /** + * Get registered gateways. + * + * @return array + */ + public function payment_gateways() { + return []; + } + }; + } + }; + } + return $newspack_blocks_test_wc; + } +} + +if ( ! function_exists( 'wc_get_checkout_url' ) ) { + /** + * Mock WooCommerce checkout URL helper. + * + * @return string + */ + function wc_get_checkout_url() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Mock WooCommerce global. + return 'https://example.com/checkout/'; + } +} + class ModalCheckoutTest extends WP_UnitTestCase_Blocks { // phpcs:ignore /** * Clean up request data. */ public function tear_down() { - global $newspack_blocks_test_limited_product_id, $newspack_blocks_test_limited_user_id; + global $newspack_blocks_test_limited_product_id, $newspack_blocks_test_limited_user_id, $newspack_blocks_test_wc; $newspack_blocks_test_limited_product_id = null; $newspack_blocks_test_limited_user_id = null; + $newspack_blocks_test_wc = null; + \Newspack\WooCommerce_My_Account::$is_from_my_account = false; remove_all_filters( 'woocommerce_cart_item_removed_message' ); unset( $_POST['billing_email'], $_POST['post_data'], $_REQUEST['modal_checkout'], $_REQUEST['post_data'] ); parent::tear_down(); @@ -416,4 +475,180 @@ public function test_get_cart_product_summary_returns_empty_for_unsupported_cart ) ); } + + /** + * Set WC()->cart to a cart double with a controllable needs_shipping_address(). + * + * @param bool $needs_shipping Whether the cart needs a shipping address. + */ + private function set_mock_checkout_cart( $needs_shipping = false ) { + WC()->cart = new class( $needs_shipping ) { + /** + * Whether the cart needs a shipping address. + * + * @var bool + */ + private $needs_shipping; + + /** + * Constructor. + * + * @param bool $needs_shipping Whether the cart needs a shipping address. + */ + public function __construct( $needs_shipping ) { + $this->needs_shipping = $needs_shipping; + } + + /** + * Whether the cart needs a shipping address. + * + * @return bool + */ + public function needs_shipping_address() { + return $this->needs_shipping; + } + }; + } + + /** + * Configure the billing fields returned by the config filter. + * + * @param string[] $billing_fields Billing field keys. + */ + private function set_configured_billing_fields( $billing_fields ) { + add_filter( + 'newspack_blocks_donate_billing_fields_keys', + function() use ( $billing_fields ) { + return $billing_fields; + } + ); + } + + /** + * Checkout fields fixture resembling the WooCommerce default structure. + * + * @return array + */ + private function get_checkout_fields_fixture() { + return [ + 'billing' => [ + 'billing_first_name' => [ 'label' => 'First name' ], + 'billing_last_name' => [ 'label' => 'Last name' ], + 'billing_country' => [ 'label' => 'Country' ], + 'billing_state' => [ 'label' => 'State' ], + 'billing_phone' => [ 'label' => 'Phone' ], + 'billing_email' => [ 'label' => 'Email' ], + ], + 'shipping' => [ + 'shipping_first_name' => [ 'label' => 'First name' ], + ], + 'order' => [ + 'order_comments' => [ 'label' => 'Order notes' ], + ], + ]; + } + + /** + * Configured-off billing fields are removed on standard (non-modal) checkout + * requests, so express checkout wallets cannot fail validation on fields the + * buyer cannot see or edit (NPPM-2937). + */ + public function test_checkout_fields_removes_configured_off_fields_on_standard_checkout() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $fields = \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $this->get_checkout_fields_fixture() ); + + $this->assertSame( + [ 'billing_first_name', 'billing_email' ], + array_keys( $fields['billing'] ), + 'Billing fields not in the configured list should be removed.' + ); + $this->assertArrayHasKey( + 'shipping_first_name', + $fields['shipping'], + 'Shipping fields should be untouched.' + ); + } + + /** + * With no custom billing fields configured, checkout fields are unchanged. + */ + public function test_checkout_fields_noop_when_no_fields_configured() { + $this->set_mock_checkout_cart(); + + $fields = $this->get_checkout_fields_fixture(); + + $this->assertSame( + $fields, + \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $fields ), + 'Fields should be unchanged when no custom billing fields are configured.' + ); + } + + /** + * Carts that need a shipping address keep the full field set. + */ + public function test_checkout_fields_noop_when_cart_needs_shipping() { + $this->set_mock_checkout_cart( true ); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $fields = $this->get_checkout_fields_fixture(); + + $this->assertSame( + $fields, + \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $fields ), + 'Fields should be unchanged when the cart needs a shipping address.' + ); + } + + /** + * Contexts without a cart keep the full field set. + */ + public function test_checkout_fields_noop_when_cart_unavailable() { + WC()->cart = null; + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $fields = $this->get_checkout_fields_fixture(); + + $this->assertSame( + $fields, + \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $fields ), + 'Fields should be unchanged when no cart is available.' + ); + } + + /** + * My Account checkouts keep the full field set (they relax required flags + * instead of removing fields). + */ + public function test_checkout_fields_noop_for_my_account_checkout() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + \Newspack\WooCommerce_My_Account::$is_from_my_account = true; + + $fields = $this->get_checkout_fields_fixture(); + + $this->assertSame( + $fields, + \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $fields ), + 'Fields should be unchanged for My Account checkouts.' + ); + } + + /** + * The billing phone field gets the form-row-last class when configured. + */ + public function test_checkout_fields_billing_phone_gets_form_row_last_class() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email', 'billing_phone' ] ); + + $fields = \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $this->get_checkout_fields_fixture() ); + + $this->assertSame( + 'form-row-last', + $fields['billing']['billing_phone']['class'], + 'The billing phone field should get the form-row-last class.' + ); + } } From 10b8db65920f66197be0a1628bd9aa551dffa97c Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Thu, 9 Jul 2026 18:46:27 -0500 Subject: [PATCH 3/8] fix(blocks): scrub invalid wallet address values on store api checkout Co-Authored-By: Claude Fable 5 --- .../includes/class-modal-checkout.php | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/plugins/newspack-blocks/includes/class-modal-checkout.php b/plugins/newspack-blocks/includes/class-modal-checkout.php index 095ae4ce06..f093f2cdb2 100644 --- a/plugins/newspack-blocks/includes/class-modal-checkout.php +++ b/plugins/newspack-blocks/includes/class-modal-checkout.php @@ -29,6 +29,17 @@ final class Modal_Checkout { */ const CHECKOUT_REGISTRATION_ORDER_META_KEY = '_newspack_checkout_registration_meta'; + /** + * Billing fields with server-side format validation in the Store API, + * mapped from their classic checkout keys to Store API address keys. + * + * @var string[] + */ + const STORE_API_VALIDATED_ADDRESS_FIELDS = [ + 'billing_state' => 'state', + 'billing_postcode' => 'postcode', + ]; + /** * Whether the modal checkout has been enqueued. * @@ -157,6 +168,8 @@ public static function init() { add_filter( 'woocommerce_get_checkout_order_received_url', [ __CLASS__, 'woocommerce_get_return_url' ], 10, 2 ); add_filter( 'wc_get_template', [ __CLASS__, 'wc_get_template' ], 10, 2 ); add_filter( 'woocommerce_checkout_fields', [ __CLASS__, 'woocommerce_checkout_fields' ] ); + add_filter( 'rest_pre_dispatch', [ __CLASS__, 'scrub_store_api_checkout_address' ], 10, 3 ); + add_filter( 'woocommerce_get_country_locale', [ __CLASS__, 'relax_configured_off_locale_fields' ] ); add_filter( 'woocommerce_update_order_review_fragments', [ __CLASS__, 'order_review_fragments' ] ); add_filter( 'woocommerce_cart_needs_payment', [ __CLASS__, 'cart_needs_payment' ] ); add_filter( 'newspack_recaptcha_verify_captcha', [ __CLASS__, 'recaptcha_verify_captcha' ], 10, 3 ); @@ -1382,6 +1395,148 @@ public static function woocommerce_checkout_fields( $fields ) { return $fields; } + /** + * Scrub invalid address values from Store API checkout requests when the + * corresponding billing fields are configured off (NPPM-2937). + * + * Express checkout wallets submit to wc/store/v1/checkout and can supply + * values the buyer never sees or corrects (e.g. Apple Pay sending a suburb + * as the state), which hard-fails Store API address validation. Values the + * validation would accept are left untouched. Runs on rest_pre_dispatch + * because the validation happens during dispatch. + * + * @param mixed $result Response to replace the requested version with. + * @param \WP_REST_Server $server Server instance. + * @param \WP_REST_Request $request Request used to generate the response. + * + * @return mixed + */ + public static function scrub_store_api_checkout_address( $result, $server, $request ) { + if ( null !== $result ) { + return $result; + } + + if ( + ! $request instanceof \WP_REST_Request || + 'POST' !== $request->get_method() || + 0 !== strpos( $request->get_route(), '/wc/store/v1/checkout' ) + ) { + return $result; + } + + if ( ! function_exists( 'WC' ) || ! class_exists( 'WC_Validation' ) ) { + return $result; + } + + $billing_fields = apply_filters( 'newspack_blocks_donate_billing_fields_keys', [] ); + + if ( empty( $billing_fields ) ) { + return $result; + } + + foreach ( [ 'billing_address', 'shipping_address' ] as $param ) { + $address = $request->get_param( $param ); + + if ( ! is_array( $address ) ) { + continue; + } + + $scrubbed = self::scrub_invalid_address_values( $address, $billing_fields ); + + if ( $scrubbed !== $address ) { + $request->set_param( $param, $scrubbed ); + } + } + + return $result; + } + + /** + * Drop configured-off address values that would fail Store API validation. + * + * @param array $address Store API address (billing or shipping). + * @param string[] $billing_fields Configured billing field keys. + * + * @return array Scrubbed address. + */ + private static function scrub_invalid_address_values( $address, $billing_fields ) { + $country = isset( $address['country'] ) ? $address['country'] : ''; + + if ( + ! in_array( 'billing_state', $billing_fields, true ) && + ! empty( $address['state'] ) && + $country + ) { + $states = \WC()->countries->get_states( $country ); + + if ( is_array( $states ) && ! empty( $states ) ) { + $code_match = array_key_exists( strtoupper( $address['state'] ), array_change_key_case( $states, CASE_UPPER ) ); + $name_match = in_array( strtolower( $address['state'] ), array_map( 'strtolower', $states ), true ); + + if ( ! $code_match && ! $name_match ) { + $address['state'] = ''; + } + } + } + + if ( + ! in_array( 'billing_postcode', $billing_fields, true ) && + ! empty( $address['postcode'] ) && + ! \WC_Validation::is_postcode( $address['postcode'], $country ) + ) { + $address['postcode'] = ''; + } + + return $address; + } + + /** + * Relax locale-required flags for configured-off billing fields (NPPM-2937). + * + * After an invalid wallet value is scrubbed (see + * scrub_store_api_checkout_address), the Store API order validation would + * still reject the checkout when the country locale marks the field as + * required. A field the site is configured not to collect cannot be + * required. + * + * @param array $locale Country locale field settings. + * + * @return array + */ + public static function relax_configured_off_locale_fields( $locale ) { + $billing_fields = apply_filters( 'newspack_blocks_donate_billing_fields_keys', [] ); + + if ( empty( $billing_fields ) ) { + return $locale; + } + + // Never relax requirements when the cart needs a shipping address: + // physical-goods flows keep the full locale rules. + if ( function_exists( 'WC' ) && \WC()->cart && \WC()->cart->needs_shipping_address() ) { + return $locale; + } + + $off = []; + + foreach ( self::STORE_API_VALIDATED_ADDRESS_FIELDS as $config_key => $address_key ) { + if ( ! in_array( $config_key, $billing_fields, true ) ) { + $off[] = $address_key; + } + } + + if ( empty( $off ) ) { + return $locale; + } + + foreach ( array_keys( $locale ) as $country ) { + foreach ( $off as $address_key ) { + $locale[ $country ][ $address_key ]['required'] = false; + } + } + + return $locale; + } + /** * If WooCommerce Subscriptions Gifting extension is available, render its fields. * From bec03d9836ea7c9a80c3f896c9c8ae1b0dfc929f Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Thu, 9 Jul 2026 18:47:49 -0500 Subject: [PATCH 4/8] test(blocks): remove billing fields filter in tear_down Co-Authored-By: Claude Fable 5 --- plugins/newspack-blocks/tests/test-modal-checkout.php | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/newspack-blocks/tests/test-modal-checkout.php b/plugins/newspack-blocks/tests/test-modal-checkout.php index 0af562fc82..3887284769 100644 --- a/plugins/newspack-blocks/tests/test-modal-checkout.php +++ b/plugins/newspack-blocks/tests/test-modal-checkout.php @@ -111,6 +111,7 @@ public function tear_down() { $newspack_blocks_test_wc = null; \Newspack\WooCommerce_My_Account::$is_from_my_account = false; remove_all_filters( 'woocommerce_cart_item_removed_message' ); + remove_all_filters( 'newspack_blocks_donate_billing_fields_keys' ); unset( $_POST['billing_email'], $_POST['post_data'], $_REQUEST['modal_checkout'], $_REQUEST['post_data'] ); parent::tear_down(); } From afb599dac7461c20371e59f5e39dc722ee1dfed2 Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Thu, 9 Jul 2026 18:55:45 -0500 Subject: [PATCH 5/8] fix(blocks): limit store api scrub to billing on non-shipping carts Co-Authored-By: Claude Fable 5 --- .../includes/class-modal-checkout.php | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/plugins/newspack-blocks/includes/class-modal-checkout.php b/plugins/newspack-blocks/includes/class-modal-checkout.php index f093f2cdb2..ef75a5456a 100644 --- a/plugins/newspack-blocks/includes/class-modal-checkout.php +++ b/plugins/newspack-blocks/includes/class-modal-checkout.php @@ -1402,8 +1402,9 @@ public static function woocommerce_checkout_fields( $fields ) { * Express checkout wallets submit to wc/store/v1/checkout and can supply * values the buyer never sees or corrects (e.g. Apple Pay sending a suburb * as the state), which hard-fails Store API address validation. Values the - * validation would accept are left untouched. Runs on rest_pre_dispatch - * because the validation happens during dispatch. + * validation would accept are left untouched, and only the billing address + * is scrubbed. Runs on rest_pre_dispatch because the validation happens + * during dispatch. * * @param mixed $result Response to replace the requested version with. * @param \WP_REST_Server $server Server instance. @@ -1434,17 +1435,21 @@ public static function scrub_store_api_checkout_address( $result, $server, $requ return $result; } - foreach ( [ 'billing_address', 'shipping_address' ] as $param ) { - $address = $request->get_param( $param ); + // Physical-goods flows are never modified. The cart is not always + // initialized this early in Store API requests; when it is, bail for + // carts that need a shipping address. Only the billing address is + // scrubbed, so shipping data is never touched either way. + if ( \WC()->cart && \WC()->cart->needs_shipping_address() ) { + return $result; + } - if ( ! is_array( $address ) ) { - continue; - } + $address = $request->get_param( 'billing_address' ); + if ( is_array( $address ) ) { $scrubbed = self::scrub_invalid_address_values( $address, $billing_fields ); if ( $scrubbed !== $address ) { - $request->set_param( $param, $scrubbed ); + $request->set_param( 'billing_address', $scrubbed ); } } From c0f3ca4fc2525868774d4840b63c76d66c0b8f0a Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Thu, 9 Jul 2026 18:55:46 -0500 Subject: [PATCH 6/8] test(blocks): cover store api scrub and locale relaxation Co-Authored-By: Claude Fable 5 --- .../tests/test-modal-checkout.php | 236 +++++++++++++++++- 1 file changed, 235 insertions(+), 1 deletion(-) diff --git a/plugins/newspack-blocks/tests/test-modal-checkout.php b/plugins/newspack-blocks/tests/test-modal-checkout.php index 3887284769..eaab238f37 100644 --- a/plugins/newspack-blocks/tests/test-modal-checkout.php +++ b/plugins/newspack-blocks/tests/test-modal-checkout.php @@ -66,6 +66,31 @@ function WC() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.Non */ public $cart = null; + /** + * Countries double exposing a US-only states list. + * + * @var object + */ + public $countries; + + /** + * Set up the countries double. + */ + public function __construct() { + $this->countries = new class() { + /** + * Get states for a country. + * + * @param string $country Country code. + * + * @return array + */ + public function get_states( $country ) { + return 'US' === $country ? [ 'CA' => 'California' ] : []; + } + }; + } + /** * Mimic WC()->payment_gateways() with no registered gateways. * @@ -89,6 +114,26 @@ public function payment_gateways() { } } +if ( ! class_exists( 'WC_Validation' ) ) { + /** + * Mock WooCommerce postcode validation. + */ + class WC_Validation { + /** + * Mock postcode check: only the literal "INVALID" fails. + * + * @param string $postcode Postcode. + * @param string $country Country code. + * + * @return bool + */ + public static function is_postcode( $postcode, $country ) { + unset( $country ); + return 'INVALID' !== $postcode; + } + } +} + if ( ! function_exists( 'wc_get_checkout_url' ) ) { /** * Mock WooCommerce checkout URL helper. @@ -109,7 +154,9 @@ public function tear_down() { $newspack_blocks_test_limited_product_id = null; $newspack_blocks_test_limited_user_id = null; $newspack_blocks_test_wc = null; - \Newspack\WooCommerce_My_Account::$is_from_my_account = false; + if ( property_exists( \Newspack\WooCommerce_My_Account::class, 'is_from_my_account' ) ) { + \Newspack\WooCommerce_My_Account::$is_from_my_account = false; + } remove_all_filters( 'woocommerce_cart_item_removed_message' ); remove_all_filters( 'newspack_blocks_donate_billing_fields_keys' ); unset( $_POST['billing_email'], $_POST['post_data'], $_REQUEST['modal_checkout'], $_REQUEST['post_data'] ); @@ -624,6 +671,10 @@ public function test_checkout_fields_noop_when_cart_unavailable() { * instead of removing fields). */ public function test_checkout_fields_noop_for_my_account_checkout() { + if ( ! property_exists( \Newspack\WooCommerce_My_Account::class, 'is_from_my_account' ) ) { + $this->markTestSkipped( 'The WooCommerce_My_Account mock is not in use.' ); + } + $this->set_mock_checkout_cart(); $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); \Newspack\WooCommerce_My_Account::$is_from_my_account = true; @@ -652,4 +703,187 @@ public function test_checkout_fields_billing_phone_gets_form_row_last_class() { 'The billing phone field should get the form-row-last class.' ); } + + /** + * Build a Store API checkout request with the given billing address. + * + * @param array $billing_address Billing address. + * + * @return WP_REST_Request + */ + private function get_store_api_checkout_request( $billing_address ) { + $request = new WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_param( 'billing_address', $billing_address ); + + return $request; + } + + /** + * An invalid state is scrubbed from Store API checkout requests when the + * state field is configured off. + */ + public function test_store_api_scrub_drops_invalid_state_when_configured_off() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $request = $this->get_store_api_checkout_request( + [ + 'country' => 'US', + 'state' => 'REMUERA', + 'postcode' => '94043', + ] + ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $address = $request->get_param( 'billing_address' ); + + $this->assertSame( '', $address['state'], 'An invalid state should be scrubbed.' ); + $this->assertSame( '94043', $address['postcode'], 'A valid postcode should be kept.' ); + } + + /** + * Valid state values pass through untouched, whether provided as a code or + * a name. + */ + public function test_store_api_scrub_keeps_valid_state_values() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + foreach ( [ 'CA', 'ca', 'California' ] as $state ) { + $request = $this->get_store_api_checkout_request( + [ + 'country' => 'US', + 'state' => $state, + ] + ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( + $state, + $request->get_param( 'billing_address' )['state'], + "A valid state value ({$state}) should be left untouched." + ); + } + } + + /** + * An invalid postcode is scrubbed when the postcode field is configured off. + */ + public function test_store_api_scrub_drops_invalid_postcode_when_configured_off() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $request = $this->get_store_api_checkout_request( + [ + 'country' => 'US', + 'postcode' => 'INVALID', + ] + ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( '', $request->get_param( 'billing_address' )['postcode'], 'An invalid postcode should be scrubbed.' ); + } + + /** + * Nothing is scrubbed when the fields are part of the configured list. + */ + public function test_store_api_scrub_noop_when_fields_configured_on() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email', 'billing_state', 'billing_postcode' ] ); + + $address = [ + 'country' => 'US', + 'state' => 'REMUERA', + 'postcode' => 'INVALID', + ]; + $request = $this->get_store_api_checkout_request( $address ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( $address, $request->get_param( 'billing_address' ), 'Configured-on fields should never be scrubbed.' ); + } + + /** + * Nothing is scrubbed with the default (empty) configuration. + */ + public function test_store_api_scrub_noop_for_default_config() { + $this->set_mock_checkout_cart(); + + $address = [ + 'country' => 'US', + 'state' => 'REMUERA', + ]; + $request = $this->get_store_api_checkout_request( $address ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( $address, $request->get_param( 'billing_address' ), 'Default configuration should never be scrubbed.' ); + } + + /** + * Carts needing a shipping address are never scrubbed. + */ + public function test_store_api_scrub_bails_for_shipping_carts() { + $this->set_mock_checkout_cart( true ); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $address = [ + 'country' => 'US', + 'state' => 'REMUERA', + ]; + $request = $this->get_store_api_checkout_request( $address ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( $address, $request->get_param( 'billing_address' ), 'Shipping carts should never be scrubbed.' ); + } + + /** + * Locale required flags are relaxed for configured-off state and postcode. + */ + public function test_locale_relaxation_for_configured_off_fields() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $locale = [ + 'US' => [ + 'state' => [ 'required' => true ], + 'postcode' => [ 'required' => true ], + 'city' => [ 'required' => true ], + ], + ]; + + $relaxed = \Newspack_Blocks\Modal_Checkout::relax_configured_off_locale_fields( $locale ); + + $this->assertFalse( $relaxed['US']['state']['required'], 'Configured-off state should not be required.' ); + $this->assertFalse( $relaxed['US']['postcode']['required'], 'Configured-off postcode should not be required.' ); + $this->assertTrue( $relaxed['US']['city']['required'], 'Fields without Store API validation should be untouched.' ); + } + + /** + * Locale required flags are untouched for shipping carts, configured-on + * fields, and the default configuration. + */ + public function test_locale_relaxation_noop_cases() { + $locale = [ + 'US' => [ 'state' => [ 'required' => true ] ], + ]; + + // Default (empty) configuration. + $this->set_mock_checkout_cart(); + $this->assertSame( $locale, \Newspack_Blocks\Modal_Checkout::relax_configured_off_locale_fields( $locale ), 'Default configuration should not relax locale fields.' ); + + // Configured-on fields. + $this->set_configured_billing_fields( [ 'billing_state', 'billing_postcode' ] ); + $this->assertSame( $locale, \Newspack_Blocks\Modal_Checkout::relax_configured_off_locale_fields( $locale ), 'Configured-on fields should not be relaxed.' ); + + // Shipping carts. + remove_all_filters( 'newspack_blocks_donate_billing_fields_keys' ); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + $this->set_mock_checkout_cart( true ); + $this->assertSame( $locale, \Newspack_Blocks\Modal_Checkout::relax_configured_off_locale_fields( $locale ), 'Shipping carts should not be relaxed.' ); + } } From 00dca7576c63715825cba7adf92429fa8a01c0f7 Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Fri, 10 Jul 2026 15:50:13 -0500 Subject: [PATCH 7/8] fix(blocks): scope billing fields enforcement to modal checkout Co-Authored-By: Claude Fable 5 --- .../includes/class-modal-checkout.php | 51 +++++++++--- .../tests/test-modal-checkout.php | 82 +++++++++++++++++-- 2 files changed, 115 insertions(+), 18 deletions(-) diff --git a/plugins/newspack-blocks/includes/class-modal-checkout.php b/plugins/newspack-blocks/includes/class-modal-checkout.php index ef75a5456a..482af76a03 100644 --- a/plugins/newspack-blocks/includes/class-modal-checkout.php +++ b/plugins/newspack-blocks/includes/class-modal-checkout.php @@ -1345,21 +1345,20 @@ public static function show_admin_bar( $show ) { } /** - * Remove the configured-off billing fields from checkout. + * Remove the configured-off billing fields from modal checkout requests. * - * Not limited to modal checkout requests (NPPM-2937): the fields must be - * unregistered wherever WooCommerce renders or validates them. When they were - * only removed from the modal render, they stayed registered on the standard - * checkout page, and express checkout wallets (e.g. Apple Pay) would populate - * and fail validation on fields the buyer could not see or edit. + * Deliberately scoped to modal checkout: some publishers rely on standard + * Woo checkout flows that predate Audience Management, so those keep the + * stock field set. The express checkout gap this leaves (wallets submitting + * values for fields the buyer cannot see) is handled for modal-originated + * requests by scrub_store_api_checkout_address() below. * * @param array $fields Checkout fields. * * @return array */ public static function woocommerce_checkout_fields( $fields ) { - // My Account checkouts relax required flags instead of removing fields. - if ( method_exists( 'Newspack\WooCommerce_My_Account', 'is_from_my_account' ) && \Newspack\WooCommerce_My_Account::is_from_my_account() ) { + if ( ! self::is_modal_checkout() ) { return $fields; } $cart = \WC()->cart; @@ -1397,7 +1396,7 @@ public static function woocommerce_checkout_fields( $fields ) { /** * Scrub invalid address values from Store API checkout requests when the - * corresponding billing fields are configured off (NPPM-2937). + * corresponding billing fields are configured off. * * Express checkout wallets submit to wc/store/v1/checkout and can supply * values the buyer never sees or corrects (e.g. Apple Pay sending a suburb @@ -1406,6 +1405,10 @@ public static function woocommerce_checkout_fields( $fields ) { * is scrubbed. Runs on rest_pre_dispatch because the validation happens * during dispatch. * + * Scoped to requests originating from the modal checkout, so any Store API + * checkout outside the modal (e.g. the blocks checkout page or express + * buttons on product pages) keeps stock behavior. + * * @param mixed $result Response to replace the requested version with. * @param \WP_REST_Server $server Server instance. * @param \WP_REST_Request $request Request used to generate the response. @@ -1425,6 +1428,10 @@ public static function scrub_store_api_checkout_address( $result, $server, $requ return $result; } + if ( ! self::is_modal_checkout_referer() ) { + return $result; + } + if ( ! function_exists( 'WC' ) || ! class_exists( 'WC_Validation' ) ) { return $result; } @@ -1456,6 +1463,23 @@ public static function scrub_store_api_checkout_address( $result, $server, $requ return $result; } + /** + * Whether the current request originates from the modal checkout, based on + * the referer. + * + * Express checkout submissions go to the Store API as JSON, so the usual + * modal_checkout request params are absent. The requests are made from the + * modal checkout iframe, whose URL carries modal_checkout=1, so it shows up + * as the (same-origin) referer. + * + * @return bool + */ + private static function is_modal_checkout_referer() { + $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + return false !== strpos( $referer, 'modal_checkout=1' ); + } + /** * Drop configured-off address values that would fail Store API validation. * @@ -1496,7 +1520,7 @@ private static function scrub_invalid_address_values( $address, $billing_fields } /** - * Relax locale-required flags for configured-off billing fields (NPPM-2937). + * Relax locale-required flags for configured-off billing fields. * * After an invalid wallet value is scrubbed (see * scrub_store_api_checkout_address), the Store API order validation would @@ -1504,11 +1528,18 @@ private static function scrub_invalid_address_values( $address, $billing_fields * required. A field the site is configured not to collect cannot be * required. * + * Scoped to modal-originated requests so standard Woo flows keep the stock + * locale rules. + * * @param array $locale Country locale field settings. * * @return array */ public static function relax_configured_off_locale_fields( $locale ) { + if ( ! self::is_modal_checkout_referer() && ! self::is_modal_checkout() ) { + return $locale; + } + $billing_fields = apply_filters( 'newspack_blocks_donate_billing_fields_keys', [] ); if ( empty( $billing_fields ) ) { diff --git a/plugins/newspack-blocks/tests/test-modal-checkout.php b/plugins/newspack-blocks/tests/test-modal-checkout.php index eaab238f37..46a75c9073 100644 --- a/plugins/newspack-blocks/tests/test-modal-checkout.php +++ b/plugins/newspack-blocks/tests/test-modal-checkout.php @@ -159,7 +159,7 @@ public function tear_down() { } remove_all_filters( 'woocommerce_cart_item_removed_message' ); remove_all_filters( 'newspack_blocks_donate_billing_fields_keys' ); - unset( $_POST['billing_email'], $_POST['post_data'], $_REQUEST['modal_checkout'], $_REQUEST['post_data'] ); + unset( $_POST['billing_email'], $_POST['post_data'], $_REQUEST['modal_checkout'], $_REQUEST['post_data'], $_SERVER['HTTP_REFERER'] ); parent::tear_down(); } @@ -572,6 +572,21 @@ function() use ( $billing_fields ) { ); } + /** + * Mark the current request as a modal checkout request. + */ + private function set_modal_checkout_request() { + $_REQUEST['modal_checkout'] = '1'; + } + + /** + * Set a referer pointing at the modal checkout iframe, as express checkout + * Store API requests carry. + */ + private function set_modal_checkout_referer() { + $_SERVER['HTTP_REFERER'] = 'https://example.com/checkout/?modal_checkout=1'; + } + /** * Checkout fields fixture resembling the WooCommerce default structure. * @@ -597,11 +612,10 @@ private function get_checkout_fields_fixture() { } /** - * Configured-off billing fields are removed on standard (non-modal) checkout - * requests, so express checkout wallets cannot fail validation on fields the - * buyer cannot see or edit (NPPM-2937). + * Configured-off billing fields are removed on modal checkout requests. */ - public function test_checkout_fields_removes_configured_off_fields_on_standard_checkout() { + public function test_checkout_fields_removes_configured_off_fields_on_modal_requests() { + $this->set_modal_checkout_request(); $this->set_mock_checkout_cart(); $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); @@ -619,10 +633,28 @@ public function test_checkout_fields_removes_configured_off_fields_on_standard_c ); } + /** + * Standard (non-modal) Woo checkouts keep the stock field set, by design: + * publisher flows that predate Audience Management must not change. + */ + public function test_checkout_fields_noop_on_standard_checkout() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $fields = $this->get_checkout_fields_fixture(); + + $this->assertSame( + $fields, + \Newspack_Blocks\Modal_Checkout::woocommerce_checkout_fields( $fields ), + 'Fields should be unchanged on standard checkout requests.' + ); + } + /** * With no custom billing fields configured, checkout fields are unchanged. */ public function test_checkout_fields_noop_when_no_fields_configured() { + $this->set_modal_checkout_request(); $this->set_mock_checkout_cart(); $fields = $this->get_checkout_fields_fixture(); @@ -638,6 +670,7 @@ public function test_checkout_fields_noop_when_no_fields_configured() { * Carts that need a shipping address keep the full field set. */ public function test_checkout_fields_noop_when_cart_needs_shipping() { + $this->set_modal_checkout_request(); $this->set_mock_checkout_cart( true ); $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); @@ -654,6 +687,7 @@ public function test_checkout_fields_noop_when_cart_needs_shipping() { * Contexts without a cart keep the full field set. */ public function test_checkout_fields_noop_when_cart_unavailable() { + $this->set_modal_checkout_request(); WC()->cart = null; $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); @@ -675,6 +709,7 @@ public function test_checkout_fields_noop_for_my_account_checkout() { $this->markTestSkipped( 'The WooCommerce_My_Account mock is not in use.' ); } + $this->set_modal_checkout_request(); $this->set_mock_checkout_cart(); $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); \Newspack\WooCommerce_My_Account::$is_from_my_account = true; @@ -692,6 +727,7 @@ public function test_checkout_fields_noop_for_my_account_checkout() { * The billing phone field gets the form-row-last class when configured. */ public function test_checkout_fields_billing_phone_gets_form_row_last_class() { + $this->set_modal_checkout_request(); $this->set_mock_checkout_cart(); $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email', 'billing_phone' ] ); @@ -712,6 +748,8 @@ public function test_checkout_fields_billing_phone_gets_form_row_last_class() { * @return WP_REST_Request */ private function get_store_api_checkout_request( $billing_address ) { + $this->set_modal_checkout_referer(); + $request = new WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); $request->set_param( 'billing_address', $billing_address ); @@ -841,10 +879,31 @@ public function test_store_api_scrub_bails_for_shipping_carts() { $this->assertSame( $address, $request->get_param( 'billing_address' ), 'Shipping carts should never be scrubbed.' ); } + /** + * Requests not originating from the modal checkout are never scrubbed, + * keeping standard Woo and blocks checkout flows stock. + */ + public function test_store_api_scrub_noop_without_modal_referer() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $address = [ + 'country' => 'US', + 'state' => 'REMUERA', + ]; + $request = $this->get_store_api_checkout_request( $address ); + unset( $_SERVER['HTTP_REFERER'] ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( $address, $request->get_param( 'billing_address' ), 'Requests without a modal checkout referer should never be scrubbed.' ); + } + /** * Locale required flags are relaxed for configured-off state and postcode. */ public function test_locale_relaxation_for_configured_off_fields() { + $this->set_modal_checkout_referer(); $this->set_mock_checkout_cart(); $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); @@ -864,16 +923,23 @@ public function test_locale_relaxation_for_configured_off_fields() { } /** - * Locale required flags are untouched for shipping carts, configured-on - * fields, and the default configuration. + * Locale required flags are untouched outside modal requests and, within + * them, for shipping carts, configured-on fields, and the default + * configuration. */ public function test_locale_relaxation_noop_cases() { $locale = [ 'US' => [ 'state' => [ 'required' => true ] ], ]; - // Default (empty) configuration. + // No modal checkout referer or request param. $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + $this->assertSame( $locale, \Newspack_Blocks\Modal_Checkout::relax_configured_off_locale_fields( $locale ), 'Non-modal requests should not relax locale fields.' ); + remove_all_filters( 'newspack_blocks_donate_billing_fields_keys' ); + + // Default (empty) configuration. + $this->set_modal_checkout_referer(); $this->assertSame( $locale, \Newspack_Blocks\Modal_Checkout::relax_configured_off_locale_fields( $locale ), 'Default configuration should not relax locale fields.' ); // Configured-on fields. From fb52290ca3d096ea922c4a56c13783d21ea2325e Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Tue, 14 Jul 2026 16:08:09 -0500 Subject: [PATCH 8/8] fix(blocks): guard non-string address values in store api scrub Co-Authored-By: Claude Fable 5 --- .../includes/class-modal-checkout.php | 16 ++++++++++++++- .../tests/test-modal-checkout.php | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/plugins/newspack-blocks/includes/class-modal-checkout.php b/plugins/newspack-blocks/includes/class-modal-checkout.php index 482af76a03..437c3740e7 100644 --- a/plugins/newspack-blocks/includes/class-modal-checkout.php +++ b/plugins/newspack-blocks/includes/class-modal-checkout.php @@ -1405,6 +1405,11 @@ public static function woocommerce_checkout_fields( $fields ) { * is scrubbed. Runs on rest_pre_dispatch because the validation happens * during dispatch. * + * The shipping address is intentionally out of scope. Carts that need a + * shipping address bail below, and wallets return only billing contact when + * shipping is not requested, so a virtual-cart request carries no shipping + * address to scrub. + * * Scoped to requests originating from the modal checkout, so any Store API * checkout outside the modal (e.g. the blocks checkout page or express * buttons on product pages) keeps stock behavior. @@ -1477,7 +1482,14 @@ public static function scrub_store_api_checkout_address( $result, $server, $requ private static function is_modal_checkout_referer() { $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended - return false !== strpos( $referer, 'modal_checkout=1' ); + if ( ! $referer ) { + return false; + } + + $query_string = \wp_parse_url( $referer, PHP_URL_QUERY ); + \wp_parse_str( (string) $query_string, $query_params ); + + return ! empty( $query_params['modal_checkout'] ); } /** @@ -1494,6 +1506,7 @@ private static function scrub_invalid_address_values( $address, $billing_fields if ( ! in_array( 'billing_state', $billing_fields, true ) && ! empty( $address['state'] ) && + is_string( $address['state'] ) && $country ) { $states = \WC()->countries->get_states( $country ); @@ -1511,6 +1524,7 @@ private static function scrub_invalid_address_values( $address, $billing_fields if ( ! in_array( 'billing_postcode', $billing_fields, true ) && ! empty( $address['postcode'] ) && + is_string( $address['postcode'] ) && ! \WC_Validation::is_postcode( $address['postcode'], $country ) ) { $address['postcode'] = ''; diff --git a/plugins/newspack-blocks/tests/test-modal-checkout.php b/plugins/newspack-blocks/tests/test-modal-checkout.php index 46a75c9073..d5edd19685 100644 --- a/plugins/newspack-blocks/tests/test-modal-checkout.php +++ b/plugins/newspack-blocks/tests/test-modal-checkout.php @@ -899,6 +899,26 @@ public function test_store_api_scrub_noop_without_modal_referer() { $this->assertSame( $address, $request->get_param( 'billing_address' ), 'Requests without a modal checkout referer should never be scrubbed.' ); } + /** + * Array-valued state/postcode do not trigger a fatal on the string transforms; + * the schema is left to reject them with its own clean error. + */ + public function test_store_api_scrub_ignores_non_string_values() { + $this->set_mock_checkout_cart(); + $this->set_configured_billing_fields( [ 'billing_first_name', 'billing_email' ] ); + + $address = [ + 'country' => 'US', + 'state' => [ 'REMUERA' ], + 'postcode' => [ 'INVALID' ], + ]; + $request = $this->get_store_api_checkout_request( $address ); + + \Newspack_Blocks\Modal_Checkout::scrub_store_api_checkout_address( null, null, $request ); + + $this->assertSame( $address, $request->get_param( 'billing_address' ), 'Non-string address values should be left for the schema to reject.' ); + } + /** * Locale required flags are relaxed for configured-off state and postcode. */