Prevent Store API orders being placed with empty/invalid fields (#50028)

* Fix spacing/capitalisation on comments.

* Use get_fields_for_location to prevent numeric keys going into locale

* Merge default locale with current locale

Required because the current locale only contains a diff against the default

* Iterate over current locale to get validate required fields

* Add unit tests to cover the state/postcode checks

* Add changelog

* Fix lint errors

* Ensure locale filter runs when tests need it.

* Update tests to reset locale after running

* Add validate_required_additional_fields function

* Add test to ensure required additional fields are passed

* Add throws tag to docblock
This commit is contained in:
Thomas Roberts 2024-08-01 14:58:06 +01:00 committed by GitHub
parent 02c639c2a3
commit e2bd308e8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 428 additions and 34 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Prevent Store API orders being placed with empty state

View File

@ -780,7 +780,7 @@ class CheckoutFields {
* @return mixed
*/
public function update_default_locale_with_fields( $locale ) {
foreach ( $this->fields_locations['address'] as $field_id => $additional_field ) {
foreach ( $this->get_fields_for_location( 'address' ) as $field_id => $additional_field ) {
if ( empty( $locale[ $field_id ] ) ) {
$locale[ $field_id ] = $additional_field;
}

View File

@ -1,12 +1,14 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStockException;
use Automattic\WooCommerce\StoreApi\Utilities\CheckoutTrait;
use Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes\AdditionalFields;
use Automattic\WooCommerce\Utilities\RestApiUtil;
/**
@ -174,6 +176,54 @@ class Checkout extends AbstractCartRoute {
);
}
/**
* Validate required additional fields on request.
*
* @param \WP_REST_Request $request Request object.
*
* @throws RouteException When a required additional field is missing.
*/
public function validate_required_additional_fields( \WP_REST_Request $request ) {
$contact_fields = $this->additional_fields_controller->get_fields_for_location( 'contact' );
$order_fields = $this->additional_fields_controller->get_fields_for_location( 'order' );
$order_and_contact_fields = array_merge( $contact_fields, $order_fields );
if ( ! empty( $order_and_contact_fields ) ) {
foreach ( $order_and_contact_fields as $field_key => $order_and_contact_field ) {
if ( $order_and_contact_field['required'] && ! isset( $request['additional_fields'][ $field_key ] ) ) {
throw new RouteException(
'woocommerce_rest_checkout_missing_required_field',
/* translators: %s: is the field label */
esc_html( sprintf( __( 'There was a problem with the provided additional fields: %s is required', 'woocommerce' ), $order_and_contact_field['label'] ) ),
400
);
}
}
}
$address_fields = $this->additional_fields_controller->get_fields_for_location( 'address' );
if ( ! empty( $address_fields ) ) {
foreach ( $address_fields as $field_key => $address_field ) {
if ( $address_field['required'] && ! isset( $request['billing_address'][ $field_key ] ) ) {
throw new RouteException(
'woocommerce_rest_checkout_missing_required_field',
/* translators: %s: is the field label */
esc_html( sprintf( __( 'There was a problem with the provided billing address: %s is required', 'woocommerce' ), $address_field['label'] ) ),
400
);
}
if ( $address_field['required'] && ! isset( $request['shipping_address'][ $field_key ] ) ) {
throw new RouteException(
'woocommerce_rest_checkout_missing_required_field',
/* translators: %s: is the field label */
esc_html( sprintf( __( 'There was a problem with the provided shipping address: %s is required', 'woocommerce' ), $address_field['label'] ) ),
400
);
}
}
}
}
/**
* Process an order.
*
@ -201,6 +251,11 @@ class Checkout extends AbstractCartRoute {
*/
$this->cart_controller->validate_cart();
/**
* Validate additional fields on request.
*/
$this->validate_required_additional_fields( $request );
/**
* Persist customer session data from the request first so that OrderController::update_addresses_from_cart
* uses the up to date customer address.

View File

@ -164,7 +164,8 @@ abstract class AbstractAddressSchema extends AbstractSchema {
$address = (array) $address;
$validation_util = new ValidationUtils();
$schema = $this->get_properties();
// omit all keys from address that are not in the schema. This should account for email.
// Omit all keys from address that are not in the schema. This should account for email.
$address = array_intersect_key( $address, $schema );
// The flow is Validate -> Sanitize -> Re-Validate
@ -179,6 +180,7 @@ abstract class AbstractAddressSchema extends AbstractSchema {
if ( empty( $schema[ $key ] ) || empty( $address[ $key ] ) ) {
continue;
}
if ( is_wp_error( rest_validate_value_from_schema( $value, $schema[ $key ], $key ) ) ) {
$errors->add(
'invalid_' . $key,

View File

@ -381,30 +381,16 @@ class OrderController {
$address = $order->get_address( $address_type );
$current_locale = isset( $all_locales[ $address['country'] ] ) ? $all_locales[ $address['country'] ] : array();
foreach ( $all_locales['default'] as $key => $value ) {
$default_value = empty( $current_locale[ $key ] ) ? [] : $current_locale[ $key ];
$current_locale[ $key ] = wp_parse_args( $default_value, $value );
}
$additional_fields = $this->additional_fields_controller->get_all_fields_from_object( $order, $address_type );
$address = array_merge( $address, $additional_fields );
$fields = $this->additional_fields_controller->get_additional_fields();
$address_fields_keys = $this->additional_fields_controller->get_address_fields_keys();
$address_fields = array_filter(
$fields,
function ( $key ) use ( $address_fields_keys ) {
return in_array( $key, $address_fields_keys, true );
},
ARRAY_FILTER_USE_KEY
);
if ( $current_locale ) {
foreach ( $current_locale as $key => $field ) {
if ( isset( $address_fields[ $key ] ) ) {
$address_fields[ $key ]['label'] = isset( $field['label'] ) ? $field['label'] : $address_fields[ $key ]['label'];
$address_fields[ $key ]['required'] = isset( $field['required'] ) ? $field['required'] : $address_fields[ $key ]['required'];
}
}
}
foreach ( $address_fields as $address_field_key => $address_field ) {
foreach ( $current_locale as $address_field_key => $address_field ) {
if ( empty( $address[ $address_field_key ] ) && $address_field['required'] ) {
/* translators: %s Field label. */
$errors->add( $address_type, sprintf( __( '%s is required', 'woocommerce' ), $address_field['label'] ), $address_field_key );

View File

@ -81,6 +81,8 @@ class AdditionalFields extends MockeryTestCase {
*/
protected function tearDown(): void {
parent::tearDown();
unset( wc()->countries->locale );
remove_all_filters( 'woocommerce_get_country_locale' );
global $wp_rest_server;
$wp_rest_server = null;
$this->unregister_fields();
@ -1545,18 +1547,65 @@ class AdditionalFields extends MockeryTestCase {
$request->set_body_params(
array(
'billing_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => 'test',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => 'cb241ab',
'country' => 'GB',
'phone' => '',
'email' => 'testaccount@test.com',
'plugin-namespace/gov-id' => 'gov id',
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => 'test',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => 'cb241ab',
'country' => 'GB',
'phone' => '',
'email' => 'testaccount@test.com',
'plugin-namespace/gov-id' => 'gov id',
'plugin-namespace/my-required-field' => 'req. field',
),
'shipping_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => 'test',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => 'cb241ab',
'country' => 'GB',
'phone' => '',
'plugin-namespace/gov-id' => 'gov id',
'plugin-namespace/my-required-field' => 'req. field',
),
'payment_method' => 'bacs',
'additional_fields' => array(
'plugin-namespace/job-function' => 'engineering',
),
)
);
$response = rest_get_server()->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 200, $response->get_status(), print_r( $data, true ) );
WC()->cart->add_to_cart( $this->products[0]->get_id(), 2 );
WC()->cart->add_to_cart( $this->products[1]->get_id(), 1 );
$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
$request->set_body_params(
array(
'billing_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => 'test',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => 'cb241ab',
'country' => 'GB',
'phone' => '',
'email' => 'testaccount@test.com',
'plugin-namespace/gov-id' => 'gov id',
'plugin-namespace/my-required-field' => 'gov id',
),
'shipping_address' => (object) array(
'first_name' => 'test',
@ -1583,6 +1632,50 @@ class AdditionalFields extends MockeryTestCase {
$this->assertEquals( 400, $response->get_status(), print_r( $data, true ) );
$this->assertEquals( \sprintf( 'There was a problem with the provided shipping address: %s is required', $label ), $data['message'], print_r( $data, true ) );
$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
$request->set_body_params(
array(
'billing_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => 'test',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => 'cb241ab',
'country' => 'GB',
'phone' => '',
'email' => 'testaccount@test.com',
'plugin-namespace/gov-id' => 'gov id',
'plugin-namespace/my-required-field' => 'gov id',
),
'shipping_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => 'test',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => 'cb241ab',
'country' => 'GB',
'phone' => '',
'plugin-namespace/gov-id' => 'gov id',
'plugin-namespace/my-required-field' => 'gov id',
),
'payment_method' => 'bacs',
'additional_fields' => array(
'plugin-namespace/job-function' => '',
),
)
);
$response = rest_get_server()->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 400, $response->get_status(), print_r( $data, true ) );
\__internal_woocommerce_blocks_deregister_checkout_field( $id );
// Ensures the field isn't registered.

View File

@ -102,17 +102,271 @@ class Checkout extends MockeryTestCase {
*/
protected function tearDown(): void {
parent::tearDown();
unset( wc()->countries->locale );
$default_zone = \WC_Shipping_Zones::get_zone( 0 );
$shipping_methods = $default_zone->get_shipping_methods();
foreach ( $shipping_methods as $method ) {
$default_zone->delete_shipping_method( $method->instance_id );
}
$default_zone->save();
remove_all_filters( 'woocommerce_get_country_locale' );
global $wp_rest_server;
$wp_rest_server = null;
}
/**
* Ensure that orders can be placed.
*/
public function test_post_data() {
$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
$request->set_body_params(
array(
'billing_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => 'test',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => 'cb241ab',
'country' => 'GB',
'phone' => '',
'email' => 'testaccount@test.com',
),
'shipping_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => 'test',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => 'cb241ab',
'country' => 'GB',
'phone' => '',
),
'payment_method' => 'bacs',
)
);
$response = rest_get_server()->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
}
/**
* Ensure that orders cannot be placed with invalid data.
*/
public function test_invalid_post_data() {
$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
// Test with empty state.
$request->set_body_params(
array(
'billing_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => '',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => '90210',
'country' => 'US',
'phone' => '',
'email' => 'testaccount@test.com',
),
'shipping_address' => (object) array(
'first_name' => 'test',
'last_name' => '',
'company' => '',
'address_1' => 'test',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => '90210',
'country' => 'US',
'phone' => '',
),
'payment_method' => 'bacs',
)
);
$response = rest_get_server()->dispatch( $request );
$this->assertEquals( 400, $response->get_status() );
// Test with invalid state.
$request->set_body_params(
array(
'billing_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => '',
'address_2' => '',
'city' => 'test',
'state' => 'GG',
'postcode' => '90210',
'country' => 'US',
'phone' => '',
'email' => 'testaccount@test.com',
),
'shipping_address' => (object) array(
'first_name' => 'test',
'last_name' => '',
'company' => '',
'address_1' => 'test',
'address_2' => '',
'city' => 'test',
'state' => 'GG',
'postcode' => '90210',
'country' => 'US',
'phone' => '',
),
'payment_method' => 'bacs',
)
);
$response = rest_get_server()->dispatch( $request );
$this->assertEquals( 400, $response->get_status() );
// Test with no state passed.
$request->set_body_params(
array(
'billing_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => '',
'address_2' => '',
'city' => 'test',
'postcode' => '90210',
'country' => 'US',
'phone' => '',
'email' => 'testaccount@test.com',
),
'shipping_address' => (object) array(
'first_name' => 'test',
'last_name' => '',
'company' => '',
'address_1' => 'test',
'address_2' => '',
'city' => 'test',
'postcode' => '90210',
'country' => 'US',
'phone' => '',
),
'payment_method' => 'bacs',
)
);
$response = rest_get_server()->dispatch( $request );
$this->assertEquals( 400, $response->get_status() );
}
/**
* Ensure that validation respects locale filtering.
*/
public function test_locale_required_filtering_post_data() {
add_filter(
'woocommerce_get_country_locale',
function ( $locale ) {
$locale['US']['state']['required'] = false;
return $locale;
}
);
$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
// Test that a country that usually requires state can be overridden with woocommerce_get_country_locale filter.
$request->set_body_params(
array(
'billing_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => 'test lane',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => '90210',
'country' => 'US',
'phone' => '123456',
'email' => 'testaccount@test.com',
),
'shipping_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => 'test',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => '90210',
'country' => 'US',
'phone' => '123456',
),
'payment_method' => 'bacs',
)
);
$response = rest_get_server()->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
}
/**
* Ensure that labels respect locale filtering.
*/
public function test_locale_label_filtering_post_data() {
add_filter(
'woocommerce_get_country_locale',
function ( $locale ) {
$locale['FR']['state']['label'] = 'French state';
$locale['FR']['state']['required'] = true;
$locale['DE']['state']['label'] = 'German state';
$locale['DE']['state']['required'] = true;
return $locale;
}
);
$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
// Test that a country that usually requires state can be overridden with woocommerce_get_country_locale filter.
$request->set_body_params(
array(
'billing_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => 'test lane',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => '90210',
'country' => 'FR',
'phone' => '123456',
'email' => 'testaccount@test.com',
),
'shipping_address' => (object) array(
'first_name' => 'test',
'last_name' => 'test',
'company' => '',
'address_1' => 'test',
'address_2' => '',
'city' => 'test',
'state' => '',
'postcode' => '90210',
'country' => 'DE',
'phone' => '123456',
),
'payment_method' => 'bacs',
)
);
$response = rest_get_server()->dispatch( $request );
$this->assertEquals( 'French state is required', $response->get_data()['data']['errors']['billing'][0] );
$this->assertEquals( 'German state is required', $response->get_data()['data']['errors']['shipping'][0] );
}
/**
* Ensure that registered extension data is correctly shown on options requests.
*/