Commit 972e6e5e989 for woocommerce
commit 972e6e5e98914b563d89bbb77fdb952a75c7a793
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date: Fri Oct 16 19:15:07 2026 -0300
Let extensions refuse a change with validators (#69683)
* Let extensions refuse a product or order change with validators
AbilityExtensions::register_validator() adds a validator for a resource.
An ActionableAbility runs the validators on the changed object, after the
field callbacks and before the one save. A refusal saves nothing and
returns the error with the namespace and a 400 status by default. An
update_callback error names its field.
With the feature off, order status changes call WC_Order::update_status()
again, so an order class such as a subscription applies its own rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Return a clear error when an extension callback throws
An exception from a field update_callback or a validator becomes a
woocommerce_ability_extension_failed error with its message, the field
or the namespace, and the 500 status. The tests register validators
inside woocommerce_ability_extensions_init, and run a validator in an
ActionableAbility of an extension.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep the message of a throwing extension callback out of the response
The exception message goes to the log only. The client gets a 500 with a
fixed message and the field or namespace of the extension that failed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Use AbilityExtensions::is_enabled() in the validator step
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Use the ability extensions name in the validator docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Run the order validator test in an extension ability
Core's order status ability keeps its trunk write, so it runs no
validators. The order validator test now uses an ActionableAbility of
an extension that changes orders, and the feature-off test uses product
update. The docs say which abilities run validators.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Use with_data() for a value that fails sanitizing
The guard came from the parent branch with the old with_status() name.
The changelog now says which abilities run validators.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Translate the wc_doing_it_wrong messages in AbilityExtensions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename AbilityExtensions to ExtensibleAbilities after the rebase
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refuse a late validator or one on an unknown resource, and keep the WC_Data_Exception error of a callback
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Register the validator before turning the feature off, so the feature-off test can fail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/add-ability-validators b/plugins/woocommerce/changelog/add-ability-validators
new file mode 100644
index 00000000000..d60ebc4ed74
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-ability-validators
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add ExtensibleAbilities::register_validator() to the experimental extensible abilities feature. A validator can refuse a change before the one save, in the product abilities and in the ActionableAbility classes of extensions.
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 244a907ea4c..b10b50058e5 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -13,8 +13,9 @@ defined( 'ABSPATH' ) || exit;
* An ability that changes one object and saves it one time. Register it with
* a subclass as the `ability_class`. The execute callback loads the object,
* checks the `extensions` input against the field schemas, changes the object
- * in memory, writes the fields with their `update_callback`, and then saves
- * it. A rejection from any step saves nothing.
+ * in memory, writes the fields with their `update_callback`, runs the validators
+ * that ExtensibleAbilities::register_validator() adds, and then saves it. A
+ * rejection from any step saves nothing.
*
* The input schema gets the `extensions` property of the resource, so the
* ability accepts the fields with no extra code.
@@ -128,6 +129,9 @@ abstract class ActionableAbility extends \WP_Ability {
}
$rejected = $this->apply_fields( $subject, $updates );
+ if ( null === $rejected && ExtensibleAbilities::is_enabled() ) {
+ $rejected = $this->validate( $subject );
+ }
if ( null !== $rejected ) {
return $rejected;
}
@@ -181,14 +185,14 @@ abstract class ActionableAbility extends \WP_Ability {
$param = 'extensions.' . $namespace . '.' . $field;
$valid = rest_validate_value_from_schema( $value, $args['schema'], $param );
if ( is_wp_error( $valid ) ) {
- return self::with_status( $valid );
+ return self::with_data( $valid, array( 'field' => $param ) );
}
$value = rest_sanitize_value_from_schema( $value, $args['schema'], $param );
if ( is_wp_error( $value ) ) {
- return self::with_status( $value );
+ return self::with_data( $value, array( 'field' => $param ) );
}
}
- $updates[] = array( $args['update_callback'], $value );
+ $updates[ 'extensions.' . $namespace . '.' . $field ] = array( $args['update_callback'], $value );
}
}
return $updates;
@@ -203,29 +207,61 @@ abstract class ActionableAbility extends \WP_Ability {
* @return \WP_Error|null A WP_Error when a callback rejects a value or throws.
*/
private function apply_fields( $subject, array $updates ): ?\WP_Error {
- foreach ( $updates as list( $callback, $value ) ) {
- try {
- $updated = call_user_func( $callback, $subject, $value );
- } catch ( \WC_Data_Exception $e ) {
- return self::with_status( new \WP_Error( $e->getErrorCode(), $e->getMessage(), $e->getErrorData() ) );
- } catch ( \Throwable $e ) {
- wc_get_logger()->error(
- sprintf( 'Ability "%s" failed to write an extension field: %s', $this->get_name(), $e->getMessage() ),
- array( 'source' => 'woocommerce-abilities' )
- );
- return new \WP_Error(
- 'woocommerce_ability_field_update_failed',
- __( 'An extension field could not be written.', 'woocommerce' ),
- array( 'status' => 500 )
- );
+ foreach ( $updates as $param => list( $callback, $value ) ) {
+ $rejected = self::run_callback( $callback, array( $subject, $value ), array( 'field' => $param ) );
+ if ( null !== $rejected ) {
+ return $rejected;
}
- if ( is_wp_error( $updated ) ) {
- return self::with_status( $updated );
+ }
+ return null;
+ }
+
+ /**
+ * Run the validators of the resource on the changed object.
+ *
+ * @param \WC_Data $subject Changed object.
+ * @return \WP_Error|null A WP_Error when a validator refuses the change.
+ */
+ private function validate( $subject ): ?\WP_Error {
+ foreach ( ExtensibleAbilities::get_validators( $this->get_resource() ) as $args ) {
+ $rejected = self::run_callback( $args['callback'], array( $subject, $this ), array( 'namespace' => $args['namespace'] ) );
+ if ( null !== $rejected ) {
+ return $rejected;
}
}
return null;
}
+ /**
+ * Run an extension callback. A WP_Error that it returns, or an exception
+ * that it throws, becomes an error with the given data. An exception is
+ * logged and becomes a 500 with a fixed message, so its text never reaches
+ * the client.
+ *
+ * @param callable $callback Field update_callback or validator.
+ * @param array $args Callback arguments.
+ * @param array<string, mixed> $data Data that names the field or the namespace.
+ * @return \WP_Error|null
+ */
+ private static function run_callback( callable $callback, array $args, array $data ): ?\WP_Error {
+ try {
+ $result = call_user_func_array( $callback, $args );
+ } catch ( \WC_Data_Exception $e ) {
+ $result = new \WP_Error( $e->getErrorCode(), $e->getMessage(), $e->getErrorData() );
+ } catch ( \Throwable $e ) {
+ wc_get_logger()->error(
+ sprintf( 'Extension callback "%s" failed: %s', implode( '', $data ), $e->getMessage() ),
+ array( 'source' => 'extensible-abilities' )
+ );
+ $result = new \WP_Error(
+ 'woocommerce_ability_extension_failed',
+ __( 'An extension failed while checking this change.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+ return is_wp_error( $result ) ? self::with_data( $result, $data ) : null;
+ }
+
/**
* Add the `extensions` property one time, at the top level of the input
* schema. It lists the fields that have an update_callback, and allows null
@@ -297,16 +333,15 @@ abstract class ActionableAbility extends \WP_Ability {
}
/**
- * Give an error the 400 status when it has none.
+ * Add data to an error, and the 400 status when it has none.
*
- * @param \WP_Error $error Error.
+ * @param \WP_Error $error Error.
+ * @param array<string, mixed> $data Data to add.
* @return \WP_Error
*/
- private static function with_status( \WP_Error $error ): \WP_Error {
- $data = $error->get_error_data();
- if ( ! isset( $data['status'] ) ) {
- $error->add_data( array_merge( is_array( $data ) ? $data : array(), array( 'status' => 400 ) ) );
- }
+ private static function with_data( \WP_Error $error, array $data ): \WP_Error {
+ $current = $error->get_error_data();
+ $error->add_data( array_merge( array( 'status' => 400 ), is_array( $current ) ? $current : array(), $data ) );
return $error;
}
}
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index 9b909df366a..91ee7228376 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -20,7 +20,8 @@ defined( 'ABSPATH' ) || exit;
* object of that resource returns the field values under
* `extensions.<namespace>.<field>`, and lists the fields in its output schema.
* An ActionableAbility writes the same `extensions` input to the fields that
- * have an `update_callback`, and saves the object one time.
+ * have an `update_callback`, runs the validators of the resource, and saves
+ * the object one time.
*
* The API is experimental while the `extensible_abilities` feature exists.
*
@@ -44,6 +45,13 @@ class ExtensibleAbilities {
*/
private static array $resources = array();
+ /**
+ * Validators keyed by resource.
+ *
+ * @var array<string, array<int, array<string, mixed>>>
+ */
+ private static array $validators = array();
+
/**
* Fields already reported for a value that does not match the schema, keyed by resource, namespace and field.
*
@@ -188,6 +196,79 @@ class ExtensibleAbilities {
self::$fields[ $args['resource'] ][ $args['namespace'] ][ $args['field'] ] = $args;
}
+ /**
+ * Register a validator that can refuse a change to an object of a resource.
+ *
+ * Call it inside the `woocommerce_extensible_abilities_init` action. Validators
+ * run only when the `extensible_abilities` feature is on.
+ *
+ * Use a field `update_callback` to refuse a bad value of one field. It runs
+ * only when the input sends that field. Use a validator to refuse a change as
+ * a whole. It runs on every change of the resource, also when the input sends
+ * no extension fields. For example, a validator refuses a product change that
+ * would break a subscription plan.
+ *
+ * Core's product abilities run validators. Core's order abilities keep their
+ * trunk write for now, so validators for `order` run only in the
+ * ActionableAbility classes of extensions.
+ *
+ * @since 11.3.0
+ *
+ * @param array $args {
+ * Validator arguments.
+ *
+ * @type string $resource Resource that an ActionableAbility changes, such as `product`.
+ * @type string $namespace Slug of the extension that owns the validator, such as `subscriptions`.
+ * @type callable $callback Receives the changed object and the ability, after Core's changes and the
+ * field callbacks, before the one save. It returns a WP_Error to refuse the
+ * change, and then nothing is saved. The error gets the namespace in its
+ * data, and the 400 status when it has no status. Its message can name the
+ * ability or screen to use instead. Rules:
+ * 1. It only reads. It does not change the object, save, send email or
+ * make HTTP requests.
+ * 2. It checks the ability name when the extension's own abilities
+ * change the same resource.
+ * }
+ */
+ public static function register_validator( array $args ): void {
+ if ( did_action( 'woocommerce_extensible_abilities_init' ) && ! doing_action( 'woocommerce_extensible_abilities_init' ) ) {
+ wc_doing_it_wrong( __METHOD__, __( 'Register a validator inside the woocommerce_extensible_abilities_init action.', 'woocommerce' ), '11.3.0' );
+ return;
+ }
+ foreach ( array( 'resource', 'namespace' ) as $key ) {
+ if ( ! is_string( $args[ $key ] ?? null ) || '' === $args[ $key ] || sanitize_key( $args[ $key ] ) !== $args[ $key ] ) {
+ wc_doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: argument name. */
+ __( 'The "%s" argument must be a slug of lowercase letters, numbers, dashes and underscores.', 'woocommerce' ),
+ $key
+ ),
+ '11.3.0'
+ );
+ return;
+ }
+ }
+ if ( ! is_callable( $args['callback'] ?? null ) ) {
+ wc_doing_it_wrong( __METHOD__, __( 'The "callback" argument must be callable.', 'woocommerce' ), '11.3.0' );
+ return;
+ }
+ if ( ! isset( self::$resources[ $args['resource'] ] ) ) {
+ wc_doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: resource name. */
+ __( 'The "%s" resource is not registered. Call ExtensibleAbilities::register_resource() first.', 'woocommerce' ),
+ $args['resource']
+ ),
+ '11.3.0'
+ );
+ return;
+ }
+
+ self::$validators[ $args['resource'] ][] = $args;
+ }
+
/**
* Add the field values of an object to its formatted output, under
* `extensions.<namespace>.<field>`. A value is cast to its schema type, so
@@ -287,6 +368,18 @@ class ExtensibleAbilities {
return self::$fields[ $resource_name ] ?? array();
}
+ /**
+ * Validators of a resource.
+ *
+ * @internal
+ *
+ * @param string $resource_name Resource.
+ * @return array<int, array<string, mixed>>
+ */
+ public static function get_validators( string $resource_name ): array {
+ return self::$validators[ $resource_name ] ?? array();
+ }
+
/**
* Whether a value matches its field schema. A value other than null that
* does not match is logged and reported one time for each field.
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 5fa811d38da..0ffe14a7d20 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -42,6 +42,13 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
*/
private $original_init_count;
+ /**
+ * Actions that a test added, removed in tearDown.
+ *
+ * @var array<int, array{0: string, 1: callable, 2: int}>
+ */
+ private $test_actions = array();
+
/**
* Create immutable class fixtures.
*
@@ -85,6 +92,10 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
global $wp_actions;
remove_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_fields' ) );
+ foreach ( $this->test_actions as list( $hook, $callback, $priority ) ) {
+ remove_action( $hook, $callback, $priority );
+ }
+ $this->test_actions = array();
$this->set_feature( false );
$this->reset_registries();
wp_set_current_user( 0 );
@@ -710,7 +721,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
$expected = array(
'throw' => array( 400, 'test_thrown' ),
- 'crash' => array( 500, 'woocommerce_ability_field_update_failed' ),
+ 'crash' => array( 500, 'woocommerce_ability_extension_failed' ),
);
foreach ( $expected as $value => list( $status, $code ) ) {
@@ -858,30 +869,8 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
* @testdox Should let an ActionableAbility of an extension accept the writable fields with no extra code.
*/
public function test_an_extension_actionable_ability_writes_the_fields(): void {
- $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
- $register = static function () {
- wp_register_ability(
- 'test-ext/product-rename',
- array(
- 'label' => 'Rename product',
- 'description' => 'Renames a product.',
- 'category' => 'woocommerce',
- 'input_schema' => array(
- 'type' => 'object',
- 'properties' => array(
- 'id' => array( 'type' => 'integer' ),
- 'name' => array( 'type' => 'string' ),
- ),
- 'additionalProperties' => false,
- ),
- 'output_schema' => ExtensibleAbilities::add_fields_schema( array( 'type' => 'object' ), 'product' ),
- 'permission_callback' => '__return_true',
- 'ability_class' => TestProductRenameAbility::class,
- 'meta' => array( 'show_in_rest' => true ),
- )
- );
- };
- add_action( 'wp_abilities_api_init', $register, 20 );
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+ $this->register_rename_ability();
$schema = $this->get_ability( 'test-ext/product-rename' )['input_schema']['properties']['extensions'];
$output = $this->run_ability(
@@ -893,7 +882,6 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
)
);
- remove_action( 'wp_abilities_api_init', $register, 20 );
$this->assertSame( array( 'string', 'null' ), $schema['properties']['test-ext']['properties']['color']['type'] );
$this->assertArrayNotHasKey( 'code', $schema['properties']['test-ext']['properties'] );
$this->assertSame( 'Pencil', wc_get_product( $product->get_id() )->get_name() );
@@ -1055,6 +1043,373 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertFalse( wc_get_product( $product->get_id() )->meta_exists( '_test_color' ) );
}
+ /**
+ * @testdox Should name the field and keep the message when an update_callback rejects a value.
+ */
+ public function test_a_rejected_value_names_the_field(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+
+ $response = $this->run_ability_response(
+ 'woocommerce/product-update',
+ array(
+ 'id' => $product->get_id(),
+ 'extensions' => array( 'other_ext' => array( 'count' => -1 ) ),
+ )
+ );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'test_rejected', $response->get_data()['code'] );
+ $this->assertSame( 'Rejected.', $response->get_data()['message'] );
+ $this->assertSame( 'extensions.other_ext.count', $response->get_data()['data']['field'] );
+ }
+
+ /**
+ * @testdox Should save nothing and run no status listeners when a validator refuses an order status change in an extension ability.
+ */
+ public function test_a_validator_refuses_a_status_change(): void {
+ $this->register_order_status_ability();
+ $order = \WC_Helper_Order::create_order();
+ $order->update_meta_data( '_test_locked', 'yes' );
+ $order->save();
+ $ability = null;
+ $this->register_validator(
+ 'order',
+ 'test-ext',
+ static function ( \WC_Order $subject, \WP_Ability $current ) use ( &$ability ) {
+ $ability = $current->get_name();
+ if ( 'yes' === $subject->get_meta( '_test_locked' ) && 'completed' === $subject->get_status() ) {
+ return new \WP_Error( 'test_locked', 'Use test-ext/order-unlock first.' );
+ }
+ return null;
+ }
+ );
+ $changes = did_action( 'woocommerce_order_status_changed' );
+
+ $response = $this->run_ability_response(
+ 'test-ext/order-status',
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'completed',
+ )
+ );
+ $allowed = $this->run_ability(
+ 'test-ext/order-status',
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'on-hold',
+ )
+ );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'test_locked', $response->get_data()['code'] );
+ $this->assertSame( 'Use test-ext/order-unlock first.', $response->get_data()['message'] );
+ $this->assertSame( 'test-ext', $response->get_data()['data']['namespace'] );
+ $this->assertSame( 1, did_action( 'woocommerce_order_status_changed' ) - $changes );
+ $this->assertSame( 'on-hold', $allowed['status'] );
+ $this->assertSame( 'test-ext/order-status', $ability );
+ }
+
+ /**
+ * @testdox Should run a validator on the changed product, after the field callbacks, and save nothing when it refuses.
+ */
+ public function test_a_validator_sees_the_changed_product(): void {
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+ $this->register_validator(
+ 'product',
+ 'other_ext',
+ static function ( \WC_Product $subject ) {
+ if ( 'Pencil' === $subject->get_name() && 'red' === $subject->get_meta( '_test_color' ) ) {
+ return new \WP_Error( 'test_red_pencil', 'No red pencils.', array( 'status' => 409 ) );
+ }
+ return null;
+ }
+ );
+
+ $response = $this->run_ability_response(
+ 'woocommerce/product-update',
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Pencil',
+ 'extensions' => array( 'test-ext' => array( 'color' => 'red' ) ),
+ )
+ );
+
+ $saved = wc_get_product( $product->get_id() );
+ $this->assertSame( 409, $response->get_status() );
+ $this->assertSame( 'other_ext', $response->get_data()['data']['namespace'] );
+ $this->assertSame( 'Pen', $saved->get_name() );
+ $this->assertFalse( $saved->meta_exists( '_test_color' ) );
+ }
+
+ /**
+ * @testdox Should run a validator in an ActionableAbility of an extension, with that ability.
+ */
+ public function test_a_validator_runs_in_an_extension_actionable_ability(): void {
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+ $this->register_rename_ability();
+ $this->register_validator(
+ 'product',
+ 'other_ext',
+ static function ( \WC_Product $subject, \WP_Ability $ability ) {
+ return 'test-ext/product-rename' === $ability->get_name()
+ ? new \WP_Error( 'test_rename_refused', 'Rename refused.' )
+ : null;
+ }
+ );
+
+ $response = $this->run_ability_response(
+ 'test-ext/product-rename',
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Pencil',
+ )
+ );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'test_rename_refused', $response->get_data()['code'] );
+ $this->assertSame( 'other_ext', $response->get_data()['data']['namespace'] );
+ $this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+ }
+
+ /**
+ * @testdox Should return a fixed message and the field or namespace when an update_callback or a validator throws, and save nothing.
+ */
+ public function test_a_callback_that_throws_returns_a_clear_error(): void {
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+ $this->add_test_action(
+ 'woocommerce_extensible_abilities_init',
+ static function () {
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'test-ext',
+ 'field' => 'broken',
+ 'schema' => array( 'type' => 'string' ),
+ 'get_callback' => '__return_null',
+ 'update_callback' => static function () {
+ throw new \RuntimeException( 'Field broke.' );
+ },
+ )
+ );
+ }
+ );
+ $this->register_validator(
+ 'product',
+ 'other_ext',
+ static function ( \WC_Product $subject ) {
+ if ( 'Boom' === $subject->get_name() ) {
+ throw new \RuntimeException( 'Validator broke.' );
+ }
+ return null;
+ }
+ );
+
+ $field = $this->run_ability_response(
+ 'woocommerce/product-update',
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Pencil',
+ 'extensions' => array( 'test-ext' => array( 'broken' => 'x' ) ),
+ )
+ );
+ $validator = $this->run_ability_response(
+ 'woocommerce/product-update',
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Boom',
+ )
+ );
+
+ $this->assertSame( 500, $field->get_status() );
+ $this->assertSame( 'woocommerce_ability_extension_failed', $field->get_data()['code'] );
+ $this->assertSame( 'An extension failed while checking this change.', $field->get_data()['message'] );
+ $this->assertSame( 'extensions.test-ext.broken', $field->get_data()['data']['field'] );
+ $this->assertSame( 500, $validator->get_status() );
+ $this->assertSame( 'An extension failed while checking this change.', $validator->get_data()['message'] );
+ $this->assertSame( 'other_ext', $validator->get_data()['data']['namespace'] );
+ $this->assertSame( 'Pen', wc_get_product( $product->get_id() )->get_name() );
+ }
+
+ /**
+ * @testdox Should not run validators with the feature off.
+ */
+ public function test_validators_do_not_run_with_the_feature_off(): void {
+ $this->register_validator(
+ 'product',
+ 'test-ext',
+ static function () {
+ return new \WP_Error( 'test_refused', 'Refused.' );
+ }
+ );
+ $this->reset_registries();
+ AbilitiesLoader::init_extensions();
+ $this->set_feature( false );
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+
+ $output = $this->run_ability(
+ 'woocommerce/product-update',
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Pencil',
+ )
+ );
+
+ $this->assertNotEmpty( ExtensibleAbilities::get_validators( 'product' ) );
+ $this->assertSame( 'Pencil', $output['product']['name'] );
+ }
+
+ /**
+ * @testdox Should refuse a validator without a slug namespace or a callback, or on an unknown resource.
+ */
+ public function test_invalid_validators_are_refused(): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_validator' );
+ $this->add_test_action(
+ 'woocommerce_extensible_abilities_init',
+ static function () {
+ $invalid = array(
+ array(
+ 'resource' => 'order',
+ 'callback' => '__return_null',
+ ),
+ array(
+ 'resource' => 'order',
+ 'namespace' => 'test-ext',
+ ),
+ array(
+ 'resource' => 'refund',
+ 'namespace' => 'test-ext',
+ 'callback' => '__return_null',
+ ),
+ );
+ foreach ( $invalid as $args ) {
+ ExtensibleAbilities::register_validator( $args );
+ }
+ }
+ );
+ $this->reset_registries();
+ AbilitiesLoader::init_extensions();
+
+ $this->assertEmpty( ExtensibleAbilities::get_validators( 'order' ) );
+ $this->assertEmpty( ExtensibleAbilities::get_validators( 'refund' ) );
+ }
+
+ /**
+ * @testdox Should refuse a validator that is registered after the woocommerce_extensible_abilities_init action.
+ */
+ public function test_a_late_validator_is_refused(): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_validator' );
+ $this->reset_registries();
+ AbilitiesLoader::init_extensions();
+
+ ExtensibleAbilities::register_validator(
+ array(
+ 'resource' => 'order',
+ 'namespace' => 'test-ext',
+ 'callback' => '__return_null',
+ )
+ );
+
+ $this->assertEmpty( ExtensibleAbilities::get_validators( 'order' ) );
+ }
+
+ /**
+ * Add an action that tearDown removes.
+ *
+ * @param string $hook Action name.
+ * @param callable $callback Callback.
+ * @param int $priority Priority.
+ */
+ private function add_test_action( string $hook, callable $callback, int $priority = 10 ): void {
+ add_action( $hook, $callback, $priority );
+ $this->test_actions[] = array( $hook, $callback, $priority );
+ }
+
+ /**
+ * Register a validator inside woocommerce_extensible_abilities_init, the way an extension does.
+ *
+ * @param string $resource_name Resource.
+ * @param string $extension_namespace Extension namespace.
+ * @param callable $callback Validator.
+ */
+ private function register_validator( string $resource_name, string $extension_namespace, callable $callback ): void {
+ $this->add_test_action(
+ 'woocommerce_extensible_abilities_init',
+ static function () use ( $resource_name, $extension_namespace, $callback ) {
+ ExtensibleAbilities::register_validator(
+ array(
+ 'resource' => $resource_name,
+ 'namespace' => $extension_namespace,
+ 'callback' => $callback,
+ )
+ );
+ }
+ );
+ }
+
+ /**
+ * Register test-ext/order-status, an ActionableAbility of an extension that changes orders.
+ */
+ private function register_order_status_ability(): void {
+ $this->add_test_action(
+ 'wp_abilities_api_init',
+ static function () {
+ wp_register_ability(
+ 'test-ext/order-status',
+ array(
+ 'label' => 'Change order status',
+ 'description' => 'Changes an order status.',
+ 'category' => 'woocommerce',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'status' => array( 'type' => 'string' ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ 'output_schema' => array( 'type' => 'object' ),
+ 'permission_callback' => '__return_true',
+ 'ability_class' => TestOrderStatusAbility::class,
+ 'meta' => array( 'show_in_rest' => true ),
+ )
+ );
+ },
+ 20
+ );
+ }
+
+ /**
+ * Register test-ext/product-rename, an ActionableAbility of an extension.
+ */
+ private function register_rename_ability(): void {
+ $this->add_test_action(
+ 'wp_abilities_api_init',
+ static function () {
+ wp_register_ability(
+ 'test-ext/product-rename',
+ array(
+ 'label' => 'Rename product',
+ 'description' => 'Renames a product.',
+ 'category' => 'woocommerce',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'name' => array( 'type' => 'string' ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ 'output_schema' => ExtensibleAbilities::add_fields_schema( array( 'type' => 'object' ), 'product' ),
+ 'permission_callback' => '__return_true',
+ 'ability_class' => TestProductRenameAbility::class,
+ 'meta' => array( 'show_in_rest' => true ),
+ )
+ );
+ },
+ 20
+ );
+ }
+
/**
* Run an ability through the REST route and return its output.
*
@@ -1127,7 +1482,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
private function reset_registries(): void {
global $wp_actions;
- foreach ( array( 'fields', 'reported' ) as $property ) {
+ foreach ( array( 'fields', 'reported', 'validators' ) as $property ) {
$reflection = new \ReflectionProperty( ExtensibleAbilities::class, $property );
$reflection->setAccessible( true );
$reflection->setValue( null, array() );
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestOrderStatusAbility.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestOrderStatusAbility.php
new file mode 100644
index 00000000000..effef82c948
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestOrderStatusAbility.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * TestOrderStatusAbility class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;
+
+use Automattic\WooCommerce\Abilities\ActionableAbility;
+
+/**
+ * An extension's write ability that changes an order status.
+ */
+class TestOrderStatusAbility extends ActionableAbility {
+
+ /**
+ * Resource that the ability changes.
+ *
+ * @return string
+ */
+ public function get_resource(): string {
+ return 'order';
+ }
+
+ /**
+ * Load the order.
+ *
+ * @param array $input Ability input.
+ * @return \WC_Order|\WP_Error
+ */
+ public function load( array $input ) {
+ $order = wc_get_order( $input['id'] );
+ return $order ? $order : new \WP_Error( 'test_not_found', 'Not found.', array( 'status' => 404 ) );
+ }
+
+ /**
+ * Set the status in memory.
+ *
+ * @param \WC_Order $subject Order.
+ * @param array $input Ability input.
+ * @return null
+ */
+ public function change( $subject, array $input ) {
+ $subject->set_status( $input['status'] );
+ return null;
+ }
+
+ /**
+ * The saved order status.
+ *
+ * @param \WC_Order $subject Saved order.
+ * @return array
+ */
+ public function prepare_response( $subject ) {
+ return array( 'status' => $subject->get_status() );
+ }
+}