diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php new file mode 100644 index 0000000000000..9fb10c205abbf --- /dev/null +++ b/src/wp-includes/abilities-api.php @@ -0,0 +1,260 @@ + $args { + * An associative array of arguments for the ability. + * + * @type string $label The human-readable label for the ability. + * @type string $description A detailed description of what the ability does. + * @type string $category The ability category slug this ability belongs to. + * @type callable $execute_callback A callback function to execute when the ability is invoked. + * Receives optional mixed input and returns mixed result or WP_Error. + * @type callable $permission_callback A callback function to check permissions before execution. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. + * } + * @return WP_Ability|null An instance of registered ability on success, null on failure. + */ +function wp_register_ability( string $name, array $args ): ?WP_Ability { + if ( ! did_action( 'wp_abilities_api_init' ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: 1: abilities_api_init, 2: string value of the ability name. */ + esc_html__( 'Abilities must be registered on the %1$s action. The ability %2$s was not registered.' ), + 'abilities_api_init', + '' . esc_html( $name ) . '' + ), + '6.9.0' + ); + return null; + } + + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->register( $name, $args ); +} + +/** + * Unregisters an ability from the Abilities API. + * + * @since 6.9.0 + * + * @see WP_Abilities_Registry::unregister() + * + * @param string $name The name of the registered ability, with its namespace. + * @return WP_Ability|null The unregistered ability instance on success, null on failure. + */ +function wp_unregister_ability( string $name ): ?WP_Ability { + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->unregister( $name ); +} + +/** + * Checks if an ability is registered. + * + * @since 6.9.0 + * + * @see WP_Abilities_Registry::is_registered() + * + * @param string $name The name of the registered ability, with its namespace. + * @return bool True if the ability is registered, false otherwise. + */ +function wp_has_ability( string $name ): bool { + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return false; + } + + return $registry->is_registered( $name ); +} + +/** + * Retrieves a registered ability using Abilities API. + * + * @since 6.9.0 + * + * @see WP_Abilities_Registry::get_registered() + * + * @param string $name The name of the registered ability, with its namespace. + * @return WP_Ability|null The registered ability instance, or null if it is not registered. + */ +function wp_get_ability( string $name ): ?WP_Ability { + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->get_registered( $name ); +} + +/** + * Retrieves all registered abilities using Abilities API. + * + * @since 6.9.0 + * + * @see WP_Abilities_Registry::get_all_registered() + * + * @return WP_Ability[] The array of registered abilities. + */ +function wp_get_abilities(): array { + $registry = WP_Abilities_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } + + return $registry->get_all_registered(); +} + +/** + * Registers a new ability category. + * + * @since 6.9.0 + * + * @see WP_Ability_Categories_Registry::register() + * + * @param string $slug The unique slug for the ability category. Must contain only lowercase + * alphanumeric characters and dashes. + * @param array $args { + * An associative array of arguments for the ability category. + * + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. + * } + * @return WP_Ability_Category|null The registered ability category instance on success, null on failure. + */ +function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category { + if ( ! did_action( 'wp_abilities_api_categories_init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: abilities_api_categories_init, 2: ability category slug. */ + __( 'Ability categories must be registered on the %1$s action. The ability category %2$s was not registered.' ), + 'wp_abilities_api_categories_init', + '' . esc_html( $slug ) . '' + ), + '6.9.0' + ); + return null; + } + + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->register( $slug, $args ); +} + +/** + * Unregisters an ability category. + * + * @since 6.9.0 + * + * @see WP_Ability_Categories_Registry::unregister() + * + * @param string $slug The slug of the registered ability category. + * @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure. + */ +function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category { + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->unregister( $slug ); +} + +/** + * Checks if an ability category is registered. + * + * @since 6.9.0 + * + * @see WP_Ability_Categories_Registry::is_registered() + * + * @param string $slug The slug of the ability category. + * @return bool True if the ability category is registered, false otherwise. + */ +function wp_has_ability_category( string $slug ): bool { + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return false; + } + + return $registry->is_registered( $slug ); +} + +/** + * Retrieves a registered ability category. + * + * @since 6.9.0 + * + * @see WP_Ability_Categories_Registry::get_registered() + * + * @param string $slug The slug of the registered ability category. + * @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered. + */ +function wp_get_ability_category( string $slug ): ?WP_Ability_Category { + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->get_registered( $slug ); +} + +/** + * Retrieves all registered ability categories. + * + * @since 6.9.0 + * + * @see WP_Ability_Categories_Registry::get_all_registered() + * + * @return WP_Ability_Category[] The array of registered ability categories. + */ +function wp_get_ability_categories(): array { + $registry = WP_Ability_Categories_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } + + return $registry->get_all_registered(); +} diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php new file mode 100644 index 0000000000000..e3086d71ba174 --- /dev/null +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -0,0 +1,320 @@ + $args { + * An associative array of arguments for the ability. + * + * @type string $label The human-readable label for the ability. + * @type string $description A detailed description of what the ability does. + * @type string $category The ability category slug this ability belongs to. + * @type callable $execute_callback A callback function to execute when the ability is invoked. + * Receives optional mixed input and returns mixed result or WP_Error. + * @type callable $permission_callback A callback function to check permissions before execution. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. + * } + * @return WP_Ability|null The registered ability instance on success, null on failure. + */ + public function register( string $name, array $args ): ?WP_Ability { + if ( ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ) { + _doing_it_wrong( + __METHOD__, + __( + 'Ability name must be a string containing a namespace prefix, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.' + ), + '6.9.0' + ); + return null; + } + + if ( $this->is_registered( $name ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability name. */ + sprintf( __( 'Ability "%s" is already registered.' ), esc_html( $name ) ), + '6.9.0' + ); + return null; + } + + /** + * Filters the ability arguments before they are validated and used to instantiate the ability. + * + * @since 6.9.0 + * + * @param array $args { + * An associative array of arguments for the ability. + * + * @type string $label The human-readable label for the ability. + * @type string $description A detailed description of what the ability does. + * @type string $category The ability category slug this ability belongs to. + * @type callable $execute_callback A callback function to execute when the ability is invoked. + * Receives optional mixed input and returns mixed result or WP_Error. + * @type callable $permission_callback A callback function to check permissions before execution. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability. + * } + * @param string $name The name of the ability, with its namespace. + */ + $args = apply_filters( 'wp_register_ability_args', $args, $name ); + + // Validate ability category exists if provided (will be validated as required in WP_Ability). + if ( isset( $args['category'] ) ) { + $category_registry = WP_Ability_Categories_Registry::get_instance(); + if ( ! $category_registry->is_registered( $args['category'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %1$s: ability category slug, %2$s: ability name */ + __( 'Ability category "%1$s" is not registered. Please register the ability category before assigning it to ability "%2$s".' ), + esc_html( $args['category'] ), + esc_html( $name ) + ), + '6.9.0' + ); + return null; + } + } + + // The class is only used to instantiate the ability, and is not a property of the ability itself. + if ( isset( $args['ability_class'] ) && ! is_a( $args['ability_class'], WP_Ability::class, true ) ) { + _doing_it_wrong( + __METHOD__, + __( 'The ability args should provide a valid `ability_class` that extends WP_Ability.' ), + '6.9.0' + ); + return null; + } + + /** @var class-string */ + $ability_class = $args['ability_class'] ?? WP_Ability::class; + unset( $args['ability_class'] ); + + try { + // WP_Ability::prepare_properties() will throw an exception if the properties are invalid. + $ability = new $ability_class( $name, $args ); + } catch ( InvalidArgumentException $e ) { + _doing_it_wrong( + __METHOD__, + $e->getMessage(), + '6.9.0' + ); + return null; + } + + $this->registered_abilities[ $name ] = $ability; + return $ability; + } + + /** + * Unregisters an ability. + * + * Do not use this method directly. Instead, use the `wp_unregister_ability()` function. + * + * @since 6.9.0 + * + * @see wp_unregister_ability() + * + * @param string $name The name of the registered ability, with its namespace. + * @return WP_Ability|null The unregistered ability instance on success, null on failure. + */ + public function unregister( string $name ): ?WP_Ability { + if ( ! $this->is_registered( $name ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability name. */ + sprintf( __( 'Ability "%s" not found.' ), esc_html( $name ) ), + '6.9.0' + ); + return null; + } + + $unregistered_ability = $this->registered_abilities[ $name ]; + unset( $this->registered_abilities[ $name ] ); + + return $unregistered_ability; + } + + /** + * Retrieves the list of all registered abilities. + * + * Do not use this method directly. Instead, use the `wp_get_abilities()` function. + * + * @since 6.9.0 + * + * @see wp_get_abilities() + * + * @return WP_Ability[] The array of registered abilities. + */ + public function get_all_registered(): array { + return $this->registered_abilities; + } + + /** + * Checks if an ability is registered. + * + * Do not use this method directly. Instead, use the `wp_has_ability()` function. + * + * @since 6.9.0 + * + * @see wp_has_ability() + * + * @param string $name The name of the registered ability, with its namespace. + * @return bool True if the ability is registered, false otherwise. + */ + public function is_registered( string $name ): bool { + return isset( $this->registered_abilities[ $name ] ); + } + + /** + * Retrieves a registered ability. + * + * Do not use this method directly. Instead, use the `wp_get_ability()` function. + * + * @since 6.9.0 + * + * @see wp_get_ability() + * + * @param string $name The name of the registered ability, with its namespace. + * @return ?WP_Ability The registered ability instance, or null if it is not registered. + */ + public function get_registered( string $name ): ?WP_Ability { + if ( ! $this->is_registered( $name ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability name. */ + sprintf( esc_html__( 'Ability "%s" not found.' ), esc_attr( $name ) ), + '6.9.0' + ); + return null; + } + return $this->registered_abilities[ $name ]; + } + + /** + * Utility method to retrieve the main instance of the registry class. + * + * The instance will be created if it does not exist yet. + * + * @since 6.9.0 + * + * @return WP_Abilities_Registry|null The main registry instance, or null when `init` action has not fired. + */ + public static function get_instance(): ?self { + if ( ! did_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + __( 'Ability API should not be initialized before the init action has fired' ) + ), + '6.9.0' + ); + return null; + } + + if ( null === self::$instance ) { + self::$instance = new self(); + + // Ensure ability category registry is initialized first to allow categories to be registered + // before abilities that depend on them. + WP_Ability_Categories_Registry::get_instance(); + + /** + * Fires when preparing abilities registry. + * + * Abilities should be created and register their hooks on this action rather + * than another action to ensure they're only loaded when needed. + * + * @since 6.9.0 + * + * @param WP_Abilities_Registry $instance Abilities registry object. + */ + do_action( 'wp_abilities_api_init', self::$instance ); + } + + return self::$instance; + } + + /** + * Wakeup magic method. + * + * @since 6.9.0 + * @throws LogicException If the registry object is unserialized. + * This is a security hardening measure to prevent unserialization of the registry. + */ + public function __wakeup(): void { + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); + } + + /** + * Sleep magic method. + * + * @since 6.9.0 + * @throws LogicException If the registry object is serialized. + * This is a security hardening measure to prevent serialization of the registry. + */ + public function __sleep(): array { + throw new LogicException( __CLASS__ . ' should never be serialized' ); + } +} diff --git a/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php b/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php new file mode 100644 index 0000000000000..6a372c6d8cc82 --- /dev/null +++ b/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php @@ -0,0 +1,254 @@ + $args { + * An associative array of arguments for the ability category. + * + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. + * } + * @return WP_Ability_Category|null The registered ability category instance on success, null on failure. + */ + public function register( string $slug, array $args ): ?WP_Ability_Category { + if ( $this->is_registered( $slug ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability category slug. */ + sprintf( __( 'Ability category "%s" is already registered.' ), esc_html( $slug ) ), + '6.9.0' + ); + return null; + } + + if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Ability category slug must contain only lowercase alphanumeric characters and dashes.' ), + '6.9.0' + ); + return null; + } + + /** + * Filters the ability category arguments before they are validated and used to instantiate the ability category. + * + * @since 6.9.0 + * + * @param array $args { + * The arguments used to instantiate the ability category. + * + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. + * } + * @param string $slug The slug of the ability category. + */ + $args = apply_filters( 'wp_register_ability_category_args', $args, $slug ); + + try { + // WP_Ability_Category::prepare_properties() will throw an exception if the properties are invalid. + $category = new WP_Ability_Category( $slug, $args ); + } catch ( InvalidArgumentException $e ) { + _doing_it_wrong( + __METHOD__, + $e->getMessage(), + '6.9.0' + ); + return null; + } + + $this->registered_categories[ $slug ] = $category; + return $category; + } + + /** + * Unregisters an ability category. + * + * Do not use this method directly. Instead, use the `wp_unregister_ability_category()` function. + * + * @since 6.9.0 + * + * @see wp_unregister_ability_category() + * + * @param string $slug The slug of the registered ability category. + * @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure. + */ + public function unregister( string $slug ): ?WP_Ability_Category { + if ( ! $this->is_registered( $slug ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability category slug. */ + sprintf( __( 'Ability category "%s" not found.' ), esc_html( $slug ) ), + '6.9.0' + ); + return null; + } + + $unregistered_category = $this->registered_categories[ $slug ]; + unset( $this->registered_categories[ $slug ] ); + + return $unregistered_category; + } + + /** + * Retrieves the list of all registered ability categories. + * + * Do not use this method directly. Instead, use the `wp_get_ability_categories()` function. + * + * @since 6.9.0 + * + * @see wp_get_ability_categories() + * + * @return array The array of registered ability categories. + */ + public function get_all_registered(): array { + return $this->registered_categories; + } + + /** + * Checks if an ability category is registered. + * + * Do not use this method directly. Instead, use the `wp_has_ability_category()` function. + * + * @since 6.9.0 + * + * @see wp_has_ability_category() + * + * @param string $slug The slug of the ability category. + * @return bool True if the ability category is registered, false otherwise. + */ + public function is_registered( string $slug ): bool { + return isset( $this->registered_categories[ $slug ] ); + } + + /** + * Retrieves a registered ability category. + * + * Do not use this method directly. Instead, use the `wp_get_ability_category()` function. + * + * @since 6.9.0 + * + * @see wp_get_ability_category() + * + * @param string $slug The slug of the registered ability category. + * @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered. + */ + public function get_registered( string $slug ): ?WP_Ability_Category { + if ( ! $this->is_registered( $slug ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Ability category slug. */ + sprintf( __( 'Ability category "%s" not found.' ), esc_html( $slug ) ), + '6.9.0' + ); + return null; + } + return $this->registered_categories[ $slug ]; + } + + /** + * Utility method to retrieve the main instance of the registry class. + * + * The instance will be created if it does not exist yet. + * + * @since 6.9.0 + * + * @return WP_Ability_Categories_Registry|null The main registry instance, or null when `init` action has not fired. + */ + public static function get_instance(): ?self { + if ( ! did_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + __( 'Ability API should not be initialized before the init action has fired' ) + ), + '6.9.0' + ); + return null; + } + + if ( null === self::$instance ) { + self::$instance = new self(); + + /** + * Fires when preparing ability categories registry. + * + * Ability categories should be registered on this action to ensure they're available when needed. + * + * @since 6.9.0 + * + * @param WP_Ability_Categories_Registry $instance Ability categories registry object. + */ + do_action( 'wp_abilities_api_categories_init', self::$instance ); + } + + return self::$instance; + } + + /** + * Wakeup magic method. + * + * @since 6.9.0 + * @throws LogicException If the registry object is unserialized. + * This is a security hardening measure to prevent unserialization of the registry. + */ + public function __wakeup(): void { + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); + } + + /** + * Sleep magic method. + * + * @since 6.9.0 + * @throws LogicException If the registry object is serialized. + * This is a security hardening measure to prevent serialization of the registry. + */ + public function __sleep(): array { + throw new LogicException( __CLASS__ . ' should never be serialized' ); + } +} diff --git a/src/wp-includes/abilities-api/class-wp-ability-category.php b/src/wp-includes/abilities-api/class-wp-ability-category.php new file mode 100644 index 0000000000000..df6c89701885a --- /dev/null +++ b/src/wp-includes/abilities-api/class-wp-ability-category.php @@ -0,0 +1,216 @@ + + */ + protected $meta = array(); + + /** + * Constructor. + * + * Do not use this constructor directly. Instead, use the `wp_register_ability_category()` function. + * + * @access private + * + * @since 6.9.0 + * + * @see wp_register_ability_category() + * + * @param string $slug The unique slug for the ability category. + * @param array $args { + * An associative array of arguments for the ability category. + * + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. + * } + */ + public function __construct( string $slug, array $args ) { + if ( empty( $slug ) ) { + throw new InvalidArgumentException( + esc_html__( 'The ability category slug cannot be empty.' ) + ); + } + + $this->slug = $slug; + + $properties = $this->prepare_properties( $args ); + + foreach ( $properties as $property_name => $property_value ) { + if ( ! property_exists( $this, $property_name ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: Property name. */ + __( 'Property "%1$s" is not a valid property for ability category "%2$s". Please check the %3$s class for allowed properties.' ), + '' . esc_html( $property_name ) . '', + '' . esc_html( $this->slug ) . '', + '' . __CLASS__ . '' + ), + '6.9.0' + ); + continue; + } + + $this->$property_name = $property_value; + } + } + + /** + * Prepares and validates the properties used to instantiate the ability category. + * + * @since 6.9.0 + * + * @param array $args $args { + * An associative array of arguments used to instantiate the ability category class. + * + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. + * } + * @return array $args { + * An associative array with validated and prepared ability category properties. + * + * @type string $label The human-readable label for the ability category. + * @type string $description A description of the ability category. + * @type array $meta Optional. Additional metadata for the ability category. + * } + * @throws InvalidArgumentException if an argument is invalid. + */ + protected function prepare_properties( array $args ): array { + // Required args must be present and of the correct type. + if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) { + throw new InvalidArgumentException( + __( 'The ability category properties must contain a `label` string.' ) + ); + } + + if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) { + throw new InvalidArgumentException( + __( 'The ability category properties must contain a `description` string.' ) + ); + } + + // Optional args only need to be of the correct type if they are present. + if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) { + throw new InvalidArgumentException( + __( 'The ability category properties should provide a valid `meta` array.' ) + ); + } + + return $args; + } + + /** + * Retrieves the slug of the ability category. + * + * @since 6.9.0 + * + * @return string The ability category slug. + */ + public function get_slug(): string { + return $this->slug; + } + + /** + * Retrieves the human-readable label for the ability category. + * + * @since 6.9.0 + * + * @return string The human-readable ability category label. + */ + public function get_label(): string { + return $this->label; + } + + /** + * Retrieves the detailed description for the ability category. + * + * @since 6.9.0 + * + * @return string The detailed description for the ability category. + */ + public function get_description(): string { + return $this->description; + } + + /** + * Retrieves the metadata for the ability category. + * + * @since 6.9.0 + * + * @return array The metadata for the ability category. + */ + public function get_meta(): array { + return $this->meta; + } + + /** + * Wakeup magic method. + * + * @since 6.9.0 + * @throws LogicException If the ability category object is unserialized. + * This is a security hardening measure to prevent unserialization of the ability category. + */ + public function __wakeup(): void { + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); + } + + /** + * Sleep magic method. + * + * @since 6.9.0 + * @throws LogicException If the ability category object is serialized. + * This is a security hardening measure to prevent serialization of the ability category. + */ + public function __sleep(): array { + throw new LogicException( __CLASS__ . ' should never be serialized' ); + } +} diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php new file mode 100644 index 0000000000000..0c7310f0343bb --- /dev/null +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -0,0 +1,617 @@ + + */ + protected static $default_annotations = array( + // If true, the ability does not modify its environment. + 'readonly' => null, + /* + * If true, the ability may perform destructive updates to its environment. + * If false, the ability performs only additive updates. + */ + 'destructive' => null, + /* + * If true, calling the ability repeatedly with the same arguments will have no additional effect + * on its environment. + */ + 'idempotent' => null, + ); + + /** + * The name of the ability, with its namespace. + * Example: `my-plugin/my-ability`. + * + * @since 6.9.0 + * @var string + */ + protected $name; + + /** + * The human-readable ability label. + * + * @since 6.9.0 + * @var string + */ + protected $label; + + /** + * The detailed ability description. + * + * @since 6.9.0 + * @var string + */ + protected $description; + + /** + * The ability category. + * + * @since 6.9.0 + * @var string + */ + protected $category; + + /** + * The optional ability input schema. + * + * @since 6.9.0 + * @var array + */ + protected $input_schema = array(); + + /** + * The optional ability output schema. + * + * @since 6.9.0 + * @var array + */ + protected $output_schema = array(); + + /** + * The ability execute callback. + * + * @since 6.9.0 + * @var callable( mixed $input= ): (mixed|WP_Error) + */ + protected $execute_callback; + + /** + * The optional ability permission callback. + * + * @since 6.9.0 + * @var callable( mixed $input= ): (bool|WP_Error) + */ + protected $permission_callback; + + /** + * The optional ability metadata. + * + * @since 6.9.0 + * @var array + */ + protected $meta; + + /** + * Constructor. + * + * Do not use this constructor directly. Instead, use the `wp_register_ability()` function. + * + * @access private + * + * @since 6.9.0 + * + * @see wp_register_ability() + * + * @param string $name The name of the ability, with its namespace. + * @param array $args { + * An associative array of arguments for the ability. + * + * @type string $label The human-readable label for the ability. + * @type string $description A detailed description of what the ability does. + * @type string $category The ability category slug this ability belongs to. + * @type callable $execute_callback A callback function to execute when the ability is invoked. + * Receives optional mixed input and returns mixed result or WP_Error. + * @type callable $permission_callback A callback function to check permissions before execution. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * } + */ + public function __construct( string $name, array $args ) { + $this->name = $name; + + $properties = $this->prepare_properties( $args ); + + foreach ( $properties as $property_name => $property_value ) { + if ( ! property_exists( $this, $property_name ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: Property name. */ + __( 'Property "%1$s" is not a valid property for ability "%2$s". Please check the %3$s class for allowed properties.' ), + '' . esc_html( $property_name ) . '', + '' . esc_html( $this->name ) . '', + '' . self::class . '' + ), + '6.9.0' + ); + continue; + } + + $this->$property_name = $property_value; + } + } + + /** + * Prepares and validates the properties used to instantiate the ability. + * + * Errors are thrown as exceptions instead of WP_Errors to allow for simpler handling and overloading. They are then + * caught and converted to a WP_Error when by WP_Abilities_Registry::register(). + * + * @since 6.9.0 + * + * @see WP_Abilities_Registry::register() + * + * @param array $args { + * An associative array of arguments used to instantiate the ability class. + * + * @type string $label The human-readable label for the ability. + * @type string $description A detailed description of what the ability does. + * @type string $category The ability category slug this ability belongs to. + * @type callable $execute_callback A callback function to execute when the ability is invoked. + * Receives optional mixed input and returns mixed result or WP_Error. + * @type callable $permission_callback A callback function to check permissions before execution. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. Required if ability accepts an input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Optional. Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false. + * } + * } + * @return array { + * An associative array of arguments with validated and prepared properties for the ability class. + * + * @type string $label The human-readable label for the ability. + * @type string $description A detailed description of what the ability does. + * @type string $category The ability category slug this ability belongs to. + * @type callable $execute_callback A callback function to execute when the ability is invoked. + * Receives optional mixed input and returns mixed result or WP_Error. + * @type callable $permission_callback A callback function to check permissions before execution. + * Receives optional mixed input and returns bool or WP_Error. + * @type array $input_schema Optional. JSON Schema definition for the ability's input. + * @type array $output_schema Optional. JSON Schema definition for the ability's output. + * @type array $meta { + * Additional metadata for the ability. + * + * @type array $annotations Optional. Annotation metadata for the ability. + * @type bool $show_in_rest Whether to expose this ability in the REST API. Default false. + * } + * } + * @throws InvalidArgumentException if an argument is invalid. + */ + protected function prepare_properties( array $args ): array { + // Required args must be present and of the correct type. + if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) { + throw new InvalidArgumentException( + __( 'The ability properties must contain a `label` string.' ) + ); + } + + if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) { + throw new InvalidArgumentException( + __( 'The ability properties must contain a `description` string.' ) + ); + } + + if ( empty( $args['category'] ) || ! is_string( $args['category'] ) ) { + throw new InvalidArgumentException( + __( 'The ability properties must contain a `category` string.' ) + ); + } + + if ( empty( $args['execute_callback'] ) || ! is_callable( $args['execute_callback'] ) ) { + throw new InvalidArgumentException( + __( 'The ability properties must contain a valid `execute_callback` function.' ) + ); + } + + if ( empty( $args['permission_callback'] ) || ! is_callable( $args['permission_callback'] ) ) { + throw new InvalidArgumentException( + __( 'The ability properties must provide a valid `permission_callback` function.' ) + ); + } + + // Optional args only need to be of the correct type if they are present. + if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) { + throw new InvalidArgumentException( + __( 'The ability properties should provide a valid `input_schema` definition.' ) + ); + } + + if ( isset( $args['output_schema'] ) && ! is_array( $args['output_schema'] ) ) { + throw new InvalidArgumentException( + __( 'The ability properties should provide a valid `output_schema` definition.' ) + ); + } + + if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) { + throw new InvalidArgumentException( + __( 'The ability properties should provide a valid `meta` array.' ) + ); + } + + if ( isset( $args['meta']['annotations'] ) && ! is_array( $args['meta']['annotations'] ) ) { + throw new InvalidArgumentException( + __( 'The ability meta should provide a valid `annotations` array.' ) + ); + } + + if ( isset( $args['meta']['show_in_rest'] ) && ! is_bool( $args['meta']['show_in_rest'] ) ) { + throw new InvalidArgumentException( + __( 'The ability meta should provide a valid `show_in_rest` boolean.' ) + ); + } + + // Set defaults for optional meta. + $args['meta'] = wp_parse_args( + $args['meta'] ?? array(), + array( + 'annotations' => static::$default_annotations, + 'show_in_rest' => self::DEFAULT_SHOW_IN_REST, + ) + ); + $args['meta']['annotations'] = wp_parse_args( + $args['meta']['annotations'], + static::$default_annotations + ); + + return $args; + } + + /** + * Retrieves the name of the ability, with its namespace. + * Example: `my-plugin/my-ability`. + * + * @since 6.9.0 + * + * @return string The ability name, with its namespace. + */ + public function get_name(): string { + return $this->name; + } + + /** + * Retrieves the human-readable label for the ability. + * + * @since 6.9.0 + * + * @return string The human-readable ability label. + */ + public function get_label(): string { + return $this->label; + } + + /** + * Retrieves the detailed description for the ability. + * + * @since 6.9.0 + * + * @return string The detailed description for the ability. + */ + public function get_description(): string { + return $this->description; + } + + /** + * Retrieves the ability category for the ability. + * + * @since 6.9.0 + * + * @return string The ability category for the ability. + */ + public function get_category(): string { + return $this->category; + } + + /** + * Retrieves the input schema for the ability. + * + * @since 6.9.0 + * + * @return array The input schema for the ability. + */ + public function get_input_schema(): array { + return $this->input_schema; + } + + /** + * Retrieves the output schema for the ability. + * + * @since 6.9.0 + * + * @return array The output schema for the ability. + */ + public function get_output_schema(): array { + return $this->output_schema; + } + + /** + * Retrieves the metadata for the ability. + * + * @since 6.9.0 + * + * @return array The metadata for the ability. + */ + public function get_meta(): array { + return $this->meta; + } + + /** + * Retrieves a specific metadata item for the ability. + * + * @since 6.9.0 + * + * @param string $key The metadata key to retrieve. + * @param mixed $default_value Optional. The default value to return if the metadata item is not found. Default `null`. + * @return mixed The value of the metadata item, or the default value if not found. + */ + public function get_meta_item( string $key, $default_value = null ) { + return array_key_exists( $key, $this->meta ) ? $this->meta[ $key ] : $default_value; + } + + /** + * Validates input data against the input schema. + * + * @since 6.9.0 + * + * @param mixed $input Optional. The input data to validate. Default `null`. + * @return true|WP_Error Returns true if valid or the WP_Error object if validation fails. + */ + public function validate_input( $input = null ) { + $input_schema = $this->get_input_schema(); + if ( empty( $input_schema ) ) { + if ( null === $input ) { + return true; + } + + return new WP_Error( + 'ability_missing_input_schema', + sprintf( + /* translators: %s ability name. */ + __( 'Ability "%s" does not define an input schema required to validate the provided input.' ), + $this->name + ) + ); + } + + $valid_input = rest_validate_value_from_schema( $input, $input_schema, 'input' ); + if ( is_wp_error( $valid_input ) ) { + return new WP_Error( + 'ability_invalid_input', + sprintf( + /* translators: %1$s ability name, %2$s error message. */ + __( 'Ability "%1$s" has invalid input. Reason: %2$s' ), + $this->name, + $valid_input->get_error_message() + ) + ); + } + + return true; + } + + /** + * Invokes a callable, ensuring the input is passed through only if the input schema is defined. + * + * @since 6.9.0 + * + * @param callable $callback The callable to invoke. + * @param mixed $input Optional. The input data for the ability. Default `null`. + * @return mixed The result of the callable execution. + */ + protected function invoke_callback( callable $callback, $input = null ) { + $args = array(); + if ( ! empty( $this->get_input_schema() ) ) { + $args[] = $input; + } + + return $callback( ...$args ); + } + + /** + * Checks whether the ability has the necessary permissions. + * + * Please note that input is not automatically validated against the input schema. + * Use `validate_input()` method to validate input before calling this method if needed. + * + * @since 6.9.0 + * + * @see validate_input() + * + * @param mixed $input Optional. The valid input data for permission checking. Default `null`. + * @return bool|WP_Error Whether the ability has the necessary permission. + */ + public function check_permissions( $input = null ) { + return $this->invoke_callback( $this->permission_callback, $input ); + } + + /** + * Executes the ability callback. + * + * @since 6.9.0 + * + * @param mixed $input Optional. The input data for the ability. Default `null`. + * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. + */ + protected function do_execute( $input = null ) { + if ( ! is_callable( $this->execute_callback ) ) { + return new WP_Error( + 'ability_invalid_execute_callback', + /* translators: %s ability name. */ + sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), $this->name ) + ); + } + + return $this->invoke_callback( $this->execute_callback, $input ); + } + + /** + * Validates output data against the output schema. + * + * @since 6.9.0 + * + * @param mixed $output The output data to validate. + * @return true|WP_Error Returns true if valid, or a WP_Error object if validation fails. + */ + protected function validate_output( $output ) { + $output_schema = $this->get_output_schema(); + if ( empty( $output_schema ) ) { + return true; + } + + $valid_output = rest_validate_value_from_schema( $output, $output_schema, 'output' ); + if ( is_wp_error( $valid_output ) ) { + return new WP_Error( + 'ability_invalid_output', + sprintf( + /* translators: %1$s ability name, %2$s error message. */ + __( 'Ability "%1$s" has invalid output. Reason: %2$s' ), + $this->name, + $valid_output->get_error_message() + ) + ); + } + + return true; + } + + /** + * Executes the ability after input validation and running a permission check. + * Before returning the return value, it also validates the output. + * + * @since 6.9.0 + * + * @param mixed $input Optional. The input data for the ability. Default `null`. + * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. + */ + public function execute( $input = null ) { + $is_valid = $this->validate_input( $input ); + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } + + $has_permissions = $this->check_permissions( $input ); + if ( true !== $has_permissions ) { + if ( is_wp_error( $has_permissions ) ) { + // Don't leak the permission check error to someone without the correct perms. + _doing_it_wrong( + __METHOD__, + esc_html( $has_permissions->get_error_message() ), + '6.9.0' + ); + } + + return new WP_Error( + 'ability_invalid_permissions', + /* translators: %s ability name. */ + sprintf( __( 'Ability "%s" does not have necessary permission.' ), $this->name ) + ); + } + + /** + * Fires before an ability gets executed, after input validation and permissions check. + * + * @since 6.9.0 + * + * @param string $ability_name The name of the ability. + * @param mixed $input The input data for the ability. + */ + do_action( 'wp_before_execute_ability', $this->name, $input ); + + $result = $this->do_execute( $input ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $is_valid = $this->validate_output( $result ); + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } + + /** + * Fires immediately after an ability finished executing. + * + * @since 6.9.0 + * + * @param string $ability_name The name of the ability. + * @param mixed $input The input data for the ability. + * @param mixed $result The result of the ability execution. + */ + do_action( 'wp_after_execute_ability', $this->name, $input, $result ); + + return $result; + } + + /** + * Wakeup magic method. + * + * @since 6.9.0 + * @throws LogicException If the ability object is unserialized. + * This is a security hardening measure to prevent unserialization of the ability. + */ + public function __wakeup(): void { + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); + } + + /** + * Sleep magic method. + * + * @since 6.9.0 + * @throws LogicException If the ability object is serialized. + * This is a security hardening measure to prevent serialization of the ability. + */ + public function __sleep(): array { + throw new LogicException( __CLASS__ . ' should never be serialized' ); + } +} diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 836e0e5ec8a23..5fbdffea3d050 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -416,6 +416,12 @@ function create_initial_rest_routes() { // Font Collections. $font_collections_controller = new WP_REST_Font_Collections_Controller(); $font_collections_controller->register_routes(); + + // Abilities. + $abilities_run_controller = new WP_REST_Abilities_V1_Run_Controller(); + $abilities_run_controller->register_routes(); + $abilities_list_controller = new WP_REST_Abilities_V1_List_Controller(); + $abilities_list_controller->register_routes(); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php new file mode 100644 index 0000000000000..34566bd141ca8 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -0,0 +1,343 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[a-zA-Z0-9\-\/]+)', + array( + 'args' => array( + 'name' => array( + 'description' => __( 'Unique identifier for the ability.' ), + 'type' => 'string', + 'pattern' => '^[a-zA-Z0-9\-\/]+$', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Retrieves all abilities. + * + * @since 6.9.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response Response object on success. + */ + public function get_items( $request ) { + $abilities = array_filter( + wp_get_abilities(), + static function ( $ability ) { + return $ability->get_meta_item( 'show_in_rest' ); + } + ); + + // Filter by ability category if specified. + $category = $request['category']; + if ( ! empty( $category ) ) { + $abilities = array_filter( + $abilities, + static function ( $ability ) use ( $category ) { + return $ability->get_category() === $category; + } + ); + // Reset array keys after filtering. + $abilities = array_values( $abilities ); + } + + $page = $request['page']; + $per_page = $request['per_page']; + $offset = ( $page - 1 ) * $per_page; + + $total_abilities = count( $abilities ); + $max_pages = ceil( $total_abilities / $per_page ); + + if ( $request->get_method() === 'HEAD' ) { + $response = new WP_REST_Response( array() ); + } else { + $abilities = array_slice( $abilities, $offset, $per_page ); + + $data = array(); + foreach ( $abilities as $ability ) { + $item = $this->prepare_item_for_response( $ability, $request ); + $data[] = $this->prepare_response_for_collection( $item ); + } + + $response = rest_ensure_response( $data ); + } + + $response->header( 'X-WP-Total', (string) $total_abilities ); + $response->header( 'X-WP-TotalPages', (string) $max_pages ); + + $query_params = $request->get_query_params(); + $base = add_query_arg( urlencode_deep( $query_params ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); + + if ( $page > 1 ) { + $prev_page = $page - 1; + $prev_link = add_query_arg( 'page', $prev_page, $base ); + $response->link_header( 'prev', $prev_link ); + } + + if ( $page < $max_pages ) { + $next_page = $page + 1; + $next_link = add_query_arg( 'page', $next_page, $base ); + $response->link_header( 'next', $next_link ); + } + + return $response; + } + + /** + * Retrieves a specific ability. + * + * @since 6.9.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_item( $request ) { + $ability = wp_get_ability( $request['name'] ); + if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { + return new WP_Error( + 'rest_ability_not_found', + __( 'Ability not found.' ), + array( 'status' => 404 ) + ); + } + + $data = $this->prepare_item_for_response( $ability, $request ); + return rest_ensure_response( $data ); + } + + /** + * Checks if a given request has access to read ability items. + * + * @since 6.9.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return bool True if the request has read access. + */ + public function get_items_permissions_check( $request ) { + return current_user_can( 'read' ); + } + + /** + * Checks if a given request has access to read an ability item. + * + * @since 6.9.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return bool True if the request has read access. + */ + public function get_item_permissions_check( $request ) { + return current_user_can( 'read' ); + } + + /** + * Prepares an ability for response. + * + * @since 6.9.0 + * + * @param WP_Ability $ability The ability object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $ability, $request ) { + $data = array( + 'name' => $ability->get_name(), + 'label' => $ability->get_label(), + 'description' => $ability->get_description(), + 'category' => $ability->get_category(), + 'input_schema' => $ability->get_input_schema(), + 'output_schema' => $ability->get_output_schema(), + 'meta' => $ability->get_meta(), + ); + + $context = $request['context'] ?? 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + $response = rest_ensure_response( $data ); + + $fields = $this->get_fields_for_response( $request ); + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $links = array( + 'self' => array( + 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $ability->get_name() ) ), + ), + 'collection' => array( + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), + ), + ); + + $links['wp:action-run'] = array( + 'href' => rest_url( sprintf( '%s/%s/%s/run', $this->namespace, $this->rest_base, $ability->get_name() ) ), + ); + + $response->add_links( $links ); + } + + return $response; + } + + /** + * Retrieves the ability's schema, conforming to JSON Schema. + * + * @since 6.9.0 + * + * @return array Item schema data. + */ + public function get_item_schema(): array { + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'ability', + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'description' => __( 'Unique identifier for the ability.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'label' => array( + 'description' => __( 'Display label for the ability.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'description' => array( + 'description' => __( 'Description of the ability.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'category' => array( + 'description' => __( 'Ability category this ability belongs to.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'input_schema' => array( + 'description' => __( 'JSON Schema for the ability input.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'output_schema' => array( + 'description' => __( 'JSON Schema for the ability output.' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'meta' => array( + 'description' => __( 'Meta information about the ability.' ), + 'type' => 'object', + 'properties' => array( + 'annotations' => array( + 'description' => __( 'Annotations for the ability.' ), + 'type' => array( 'boolean', 'null' ), + 'default' => null, + ), + ), + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ), + ); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Retrieves the query params for collections. + * + * @since 6.9.0 + * + * @return array Collection parameters. + */ + public function get_collection_params(): array { + return array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + 'page' => array( + 'description' => __( 'Current page of the collection.' ), + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + ), + 'per_page' => array( + 'description' => __( 'Maximum number of items to be returned in result set.' ), + 'type' => 'integer', + 'default' => 50, + 'minimum' => 1, + 'maximum' => 100, + ), + 'category' => array( + 'description' => __( 'Limit results to abilities in specific ability category.' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => 'rest_validate_request_arg', + ), + ); + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php new file mode 100644 index 0000000000000..c8243526be723 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php @@ -0,0 +1,243 @@ +namespace, + '/' . $this->rest_base . '/(?P[a-zA-Z0-9\-\/]+?)/run', + array( + 'args' => array( + 'name' => array( + 'description' => __( 'Unique identifier for the ability.' ), + 'type' => 'string', + 'pattern' => '^[a-zA-Z0-9\-\/]+$', + ), + ), + + // TODO: We register ALLMETHODS because at route registration time, we don't know which abilities + // exist or their annotations (`destructive`, `idempotent`, `readonly`). This is due to WordPress + // load order - routes are registered early, before plugins have registered their abilities. + // This approach works but could be improved with lazy route registration or a different + // architecture that allows type-specific routes after abilities are registered. + // This was the same issue that we ended up seeing with the Feature API. + array( + 'methods' => WP_REST_Server::ALLMETHODS, + 'callback' => array( $this, 'execute_ability' ), + 'permission_callback' => array( $this, 'check_ability_permissions' ), + 'args' => $this->get_run_args(), + ), + 'schema' => array( $this, 'get_run_schema' ), + ) + ); + } + + /** + * Executes an ability. + * + * @since 6.9.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function execute_ability( $request ) { + $ability = wp_get_ability( $request['name'] ); + if ( ! $ability ) { + return new WP_Error( + 'rest_ability_not_found', + __( 'Ability not found.' ), + array( 'status' => 404 ) + ); + } + + $input = $this->get_input_from_request( $request ); + $result = $ability->execute( $input ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return rest_ensure_response( $result ); + } + + /** + * Validates if the HTTP method matches the expected method for the ability based on its annotations. + * + * @since 6.9.0 + * + * @param string $request_method The HTTP method of the request. + * @param array $annotations The ability annotations. + * @return true|WP_Error True on success, or WP_Error object on failure. + */ + public function validate_request_method( string $request_method, array $annotations ) { + $expected_method = 'POST'; + if ( ! empty( $annotations['readonly'] ) ) { + $expected_method = 'GET'; + } elseif ( ! empty( $annotations['destructive'] ) && ! empty( $annotations['idempotent'] ) ) { + $expected_method = 'DELETE'; + } + + if ( $expected_method === $request_method ) { + return true; + } + + $error_message = __( 'Abilities that perform updates require POST method.' ); + if ( 'GET' === $expected_method ) { + $error_message = __( 'Read-only abilities require GET method.' ); + } elseif ( 'DELETE' === $expected_method ) { + $error_message = __( 'Abilities that perform destructive actions require DELETE method.' ); + } + return new WP_Error( + 'rest_ability_invalid_method', + $error_message, + array( 'status' => 405 ) + ); + } + + /** + * Checks if a given request has permission to execute a specific ability. + * + * @since 6.9.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has execution permission, WP_Error object otherwise. + */ + public function check_ability_permissions( $request ) { + $ability = wp_get_ability( $request['name'] ); + if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { + return new WP_Error( + 'rest_ability_not_found', + __( 'Ability not found.' ), + array( 'status' => 404 ) + ); + } + + $is_valid = $this->validate_request_method( + $request->get_method(), + $ability->get_meta_item( 'annotations' ) + ); + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } + + $input = $this->get_input_from_request( $request ); + $is_valid = $ability->validate_input( $input ); + if ( is_wp_error( $is_valid ) ) { + $is_valid->add_data( array( 'status' => 400 ) ); + return $is_valid; + } + + $result = $ability->check_permissions( $input ); + if ( is_wp_error( $result ) ) { + $result->add_data( array( 'status' => rest_authorization_required_code() ) ); + return $result; + } + if ( ! $result ) { + return new WP_Error( + 'rest_ability_cannot_execute', + __( 'Sorry, you are not allowed to execute this ability.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Extracts input parameters from the request. + * + * @since 6.9.0 + * + * @param WP_REST_Request $request The request object. + * @return mixed|null The input parameters. + */ + private function get_input_from_request( $request ) { + if ( in_array( $request->get_method(), array( 'GET', 'DELETE' ), true ) ) { + // For GET and DELETE requests, look for 'input' query parameter. + $query_params = $request->get_query_params(); + return $query_params['input'] ?? null; + } + + // For POST requests, look for 'input' in JSON body. + $json_params = $request->get_json_params(); + return $json_params['input'] ?? null; + } + + /** + * Retrieves the arguments for ability execution endpoint. + * + * @since 6.9.0 + * + * @return array Arguments for the run endpoint. + */ + public function get_run_args(): array { + return array( + 'input' => array( + 'description' => __( 'Input parameters for the ability execution.' ), + 'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ), + 'default' => null, + ), + ); + } + + /** + * Retrieves the schema for ability execution endpoint. + * + * @since 6.9.0 + * + * @return array Schema for the run endpoint. + */ + public function get_run_schema(): array { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'ability-execution', + 'type' => 'object', + 'properties' => array( + 'result' => array( + 'description' => __( 'The result of the ability execution.' ), + 'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ), + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ), + ); + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index b67b385e791a2..2968677589b24 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -285,6 +285,11 @@ require ABSPATH . WPINC . '/nav-menu.php'; require ABSPATH . WPINC . '/admin-bar.php'; require ABSPATH . WPINC . '/class-wp-application-passwords.php'; +require ABSPATH . WPINC . '/abilities-api/class-wp-ability-category.php'; +require ABSPATH . WPINC . '/abilities-api/class-wp-ability-categories-registry.php'; +require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php'; +require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php'; +require ABSPATH . WPINC . '/abilities-api.php'; require ABSPATH . WPINC . '/rest-api.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php'; require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php'; @@ -330,6 +335,8 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-families-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-faces-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-collections-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php'; diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php new file mode 100644 index 0000000000000..95fa29a20d9f3 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -0,0 +1,651 @@ +registry = new WP_Abilities_Registry(); + + remove_all_filters( 'wp_register_ability_args' ); + + // Fire the init hook to allow test ability category registration. + do_action( 'wp_abilities_api_categories_init' ); + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + + self::$test_ability_args = array( + 'label' => 'Add numbers', + 'description' => 'Calculates the result of adding two numbers.', + 'category' => 'math', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'a' => array( + 'type' => 'number', + 'description' => 'First number.', + 'required' => true, + ), + 'b' => array( + 'type' => 'number', + 'description' => 'Second number.', + 'required' => true, + ), + ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'number', + 'description' => 'The result of adding the two numbers.', + 'required' => true, + ), + 'execute_callback' => static function ( array $input ): int { + return $input['a'] + $input['b']; + }, + 'permission_callback' => static function (): bool { + return true; + }, + 'meta' => array( + 'foo' => 'bar', + ), + ); + } + + /** + * Tear down each test method. + */ + public function tear_down(): void { + $this->registry = null; + + remove_all_filters( 'wp_register_ability_args' ); + + // Clean up registered test ability category. + wp_unregister_ability_category( 'math' ); + + parent::tear_down(); + } + + /** + * Should reject ability name without a namespace. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_name_without_namespace() { + $result = $this->registry->register( 'without-namespace', self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability name with invalid characters. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_characters_in_name() { + $result = $this->registry->register( 'still/_doing_it_wrong', array() ); + $this->assertNull( $result ); + } + + /** + * Should reject ability name with uppercase characters. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_uppercase_characters_in_name() { + $result = $this->registry->register( 'Test/AddNumbers', self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration without a label. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_missing_label() { + // Remove the label from the args. + unset( self::$test_ability_args['label'] ); + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration with invalid label type. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_label_type() { + self::$test_ability_args['label'] = false; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration without a description. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_missing_description() { + // Remove the description from the args. + unset( self::$test_ability_args['description'] ); + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration with invalid description type. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_description_type() { + self::$test_ability_args['description'] = false; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Tests registering an ability with non-existent category. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_nonexistent_category(): void { + $args = array_merge( + self::$test_ability_args, + array( 'category' => 'nonexistent' ) + ); + + $result = $this->registry->register( self::$test_ability_name, $args ); + + $this->assertNull( $result, 'Should return null when category does not exist.' ); + } + + /** + * Should reject ability registration without an execute callback. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_missing_execute_callback() { + // Remove the execute_callback from the args. + unset( self::$test_ability_args['execute_callback'] ); + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration if the execute callback is not a callable. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_incorrect_execute_callback_type() { + self::$test_ability_args['execute_callback'] = 'not-a-callback'; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration without an execute callback. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_missing_permission_callback() { + // Remove the permission_callback from the args. + unset( self::$test_ability_args['permission_callback'] ); + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration if the permission callback is not a callable. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_incorrect_permission_callback_type() { + self::$test_ability_args['permission_callback'] = 'not-a-callback'; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration if the input schema is not an array. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_incorrect_input_schema_type() { + self::$test_ability_args['input_schema'] = 'not-an-array'; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration if the output schema is not an array. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_incorrect_output_schema_type() { + self::$test_ability_args['output_schema'] = 'not-an-array'; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + + + /** + * Should reject ability registration with invalid `annotations` type. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_annotations_type() { + self::$test_ability_args['meta']['annotations'] = false; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration with invalid meta type. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_meta_type() { + self::$test_ability_args['meta'] = false; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject ability registration with invalid show in REST type. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Ability::prepare_properties + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_invalid_show_in_rest_type() { + self::$test_ability_args['meta']['show_in_rest'] = 5; + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertNull( $result ); + } + + /** + * Should reject registration for already registered ability. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_incorrect_already_registered_ability() { + $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + + $this->assertNull( $result ); + } + + /** + * Should successfully register a new ability. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + */ + public function test_register_new_ability() { + $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + + $this->assertEquals( + new WP_Ability( self::$test_ability_name, self::$test_ability_args ), + $result + ); + } + + /** + * Should return false for ability that's not registered. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::is_registered + */ + public function test_is_registered_for_unknown_ability() { + $result = $this->registry->is_registered( 'test/unknown' ); + $this->assertFalse( $result ); + } + + /** + * Should return true if ability is registered. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Abilities_Registry::is_registered + */ + public function test_is_registered_for_known_ability() { + $this->registry->register( 'test/one', self::$test_ability_args ); + $this->registry->register( 'test/two', self::$test_ability_args ); + $this->registry->register( 'test/three', self::$test_ability_args ); + + $result = $this->registry->is_registered( 'test/one' ); + $this->assertTrue( $result ); + } + + /** + * Should not find ability that's not registered. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::get_registered + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_registered + */ + public function test_get_registered_rejects_unknown_ability_name() { + $ability = $this->registry->get_registered( 'test/unknown' ); + $this->assertNull( $ability ); + } + + /** + * Should find registered ability by name. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Abilities_Registry::get_registered + */ + public function test_get_registered_for_known_ability() { + $this->registry->register( 'test/one', self::$test_ability_args ); + $this->registry->register( 'test/two', self::$test_ability_args ); + $this->registry->register( 'test/three', self::$test_ability_args ); + + $result = $this->registry->get_registered( 'test/two' ); + $this->assertEquals( 'test/two', $result->get_name() ); + } + + /** + * Unregistering should fail if an ability is not registered. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::unregister + * + * @expectedIncorrectUsage WP_Abilities_Registry::unregister + */ + public function test_unregister_not_registered_ability() { + $result = $this->registry->unregister( 'test/unregistered' ); + $this->assertNull( $result ); + } + + /** + * Should unregister ability by name. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Abilities_Registry::unregister + */ + public function test_unregister_for_known_ability() { + $this->registry->register( 'test/one', self::$test_ability_args ); + $this->registry->register( 'test/two', self::$test_ability_args ); + $this->registry->register( 'test/three', self::$test_ability_args ); + + $result = $this->registry->unregister( 'test/three' ); + $this->assertEquals( 'test/three', $result->get_name() ); + + $this->assertFalse( $this->registry->is_registered( 'test/three' ) ); + } + + /** + * Should retrieve all registered abilities. + * + * @ticket 64098 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Abilities_Registry::get_all_registered + */ + public function test_get_all_registered() { + $ability_one_name = 'test/one'; + $this->registry->register( $ability_one_name, self::$test_ability_args ); + + $ability_two_name = 'test/two'; + $this->registry->register( $ability_two_name, self::$test_ability_args ); + + $ability_three_name = 'test/three'; + $this->registry->register( $ability_three_name, self::$test_ability_args ); + + $result = $this->registry->get_all_registered(); + $this->assertCount( 3, $result ); + $this->assertSame( $ability_one_name, $result[ $ability_one_name ]->get_name() ); + $this->assertSame( $ability_two_name, $result[ $ability_two_name ]->get_name() ); + $this->assertSame( $ability_three_name, $result[ $ability_three_name ]->get_name() ); + } + + /** + * Test register_ability_args filter modifies the args before ability instantiation. + * + * @ticket 64098 + */ + public function test_register_ability_args_filter_modifies_args() { + $was_filter_callback_fired = false; + + // Define the filter. + add_filter( + 'wp_register_ability_args', + static function ( $args ) use ( &$was_filter_callback_fired ) { + $args['label'] = 'Modified label'; + $original_execute_callback = $args['execute_callback']; + $args['execute_callback'] = static function ( array $input ) use ( &$was_filter_callback_fired, $original_execute_callback ) { + $was_filter_callback_fired = true; + return $original_execute_callback( $input ); + }; + + return $args; + }, + 10 + ); + + // Register the ability. + $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + + // Check the label was modified by the filter. + $this->assertSame( 'Modified label', $ability->get_label() ); + + // Call the execute callback. + $result = $ability->execute( + array( + 'a' => 1, + 'b' => 2, + ) + ); + + $this->assertTrue( $was_filter_callback_fired, 'The execute callback defined in the filter was not fired.' ); + $this->assertSame( 3, $result, 'The original execute callback did not return the expected result.' ); + } + + /** + * Test register_ability_args filter can block ability registration by returning invalid args. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_args_filter_blocks_registration() { + // Define the filter. + add_filter( + 'wp_register_ability_args', + static function ( $args ) { + // Remove the label to make the args invalid. + unset( $args['label'] ); + return $args; + }, + 10 + ); + + // Register the ability. + $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + + // Check the ability was not registered. + $this->assertNull( $ability, 'The ability was registered even though the args were made invalid by the filter.' ); + } + + /** + * Test register_ability_args filter can block an invalid ability class from being used. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_args_filter_blocks_invalid_ability_class() { + // Define the filter. + add_filter( + 'wp_register_ability_args', + static function ( $args ) { + // Set an invalid ability class. + $args['ability_class'] = 'NonExistentClass'; + return $args; + }, + 10 + ); + // Register the ability. + $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + + // Check the ability was not registered. + $this->assertNull( $ability, 'The ability was registered even though the ability class was made invalid by the filter.' ); + } + + /** + * Tests register_ability_args filter is only applied to the specific ability being registered. + * + * @ticket 64098 + */ + public function test_register_ability_args_filter_only_applies_to_specific_ability() { + add_filter( + 'wp_register_ability_args', + static function ( $args, $name ) { + if ( self::$test_ability_name !== $name ) { + // Do not modify args for other abilities. + return $args; + } + + $args['label'] = 'Modified label for specific ability'; + return $args; + }, + 10, + 2 + ); + + // Register the first ability, which the filter should modify. + $filtered_ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); + $this->assertSame( 'Modified label for specific ability', $filtered_ability->get_label() ); + + $unfiltered_ability = $this->registry->register( 'test/another-ability', self::$test_ability_args ); + $this->assertNotSame( $filtered_ability->get_label(), $unfiltered_ability->get_label(), 'The filter incorrectly modified the args for an ability it should not have.' ); + } +} diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php new file mode 100644 index 0000000000000..910ae7dad06fe --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -0,0 +1,794 @@ + 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + + self::$test_ability_properties = array( + 'label' => 'Calculator', + 'description' => 'Calculates the result of math operations.', + 'category' => 'math', + 'output_schema' => array( + 'type' => 'number', + 'description' => 'The result of performing a math operation.', + 'required' => true, + ), + 'execute_callback' => static function (): int { + return 0; + }, + 'permission_callback' => static function (): bool { + return true; + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + ), + ), + ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + // Clean up registered test ability category. + wp_unregister_ability_category( 'math' ); + + parent::tear_down(); + } + + /** + * Direct instantiation of WP_Ability with invalid properties should throw an exception. + * + * @ticket 64098 + * + * @covers WP_Ability::__construct + * @covers WP_Ability::prepare_properties + */ + public function test_wp_ability_invalid_properties_throws_exception() { + $this->expectException( InvalidArgumentException::class ); + new WP_Ability( + 'test/invalid', + array( + 'label' => '', + 'description' => '', + 'execute_callback' => null, + ) + ); + } + + /* + * Tests that getting non-existing metadata item returns default value. + * + * @ticket 64098 + */ + public function test_meta_get_non_existing_item_returns_default() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertNull( + $ability->get_meta_item( 'non_existing' ), + 'Non-existing metadata item should return null.' + ); + } + + /** + * Tests that getting non-existing metadata item with custom default returns that default. + * + * @ticket 64098 + */ + public function test_meta_get_non_existing_item_with_custom_default() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertSame( + 'default_value', + $ability->get_meta_item( 'non_existing', 'default_value' ), + 'Non-existing metadata item should return custom default value.' + ); + } + + /** + * Tests getting all annotations when selective overrides are applied. + * + * @ticket 64098 + */ + public function test_get_merged_annotations_from_meta() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertSame( + array_merge( + self::$test_ability_properties['meta']['annotations'], + array( + 'idempotent' => null, + ) + ), + $ability->get_meta_item( 'annotations' ) + ); + } + + /** + * Tests getting default annotations when not provided. + * + * @ticket 64098 + */ + public function test_get_default_annotations_from_meta() { + $args = self::$test_ability_properties; + unset( $args['meta']['annotations'] ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( + array( + 'readonly' => null, + 'destructive' => null, + 'idempotent' => null, + ), + $ability->get_meta_item( 'annotations' ) + ); + } + + /** + * Tests getting all annotations when values overridden. + * + * @ticket 64098 + */ + public function test_get_overridden_annotations_from_meta() { + $annotations = array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => false, + ); + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'annotations' => $annotations, + ), + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( $annotations, $ability->get_meta_item( 'annotations' ) ); + } + + /** + * Tests that invalid `annotations` value throws an exception. + * + * @ticket 64098 + */ + public function test_annotations_from_meta_throws_exception() { + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'annotations' => 5, + ), + ) + ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability meta should provide a valid `annotations` array.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + + /** + * Tests that `show_in_rest` metadata defaults to false when not provided. + * + * @ticket 64098 + */ + public function test_meta_show_in_rest_defaults_to_false() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertFalse( + $ability->get_meta_item( 'show_in_rest' ), + '`show_in_rest` metadata should default to false.' + ); + } + + /** + * Tests that `show_in_rest` metadata can be set to true. + * + * @ticket 64098 + */ + public function test_meta_show_in_rest_can_be_set_to_true() { + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertTrue( + $ability->get_meta_item( 'show_in_rest' ), + '`show_in_rest` metadata should be true.' + ); + } + + /** + * Tests that `show_in_rest` can be set to false. + * + * @ticket 64098 + */ + public function test_show_in_rest_can_be_set_to_false() { + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'show_in_rest' => false, + ), + ) + ); + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertFalse( + $ability->get_meta_item( 'show_in_rest' ), + '`show_in_rest` metadata should be false.' + ); + } + + /** + * Tests that invalid `show_in_rest` value throws an exception. + * + * @ticket 64098 + */ + public function test_show_in_rest_throws_exception() { + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'show_in_rest' => 5, + ), + ) + ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability meta should provide a valid `show_in_rest` boolean.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + + /** + * Data provider for testing the execution of the ability. + * + * @return array Data sets with different configurations. + */ + public function data_execute_input() { + return array( + 'null input' => array( + array( + 'type' => array( 'null', 'integer' ), + 'description' => 'The null or integer to convert to integer.', + 'required' => true, + ), + static function ( $input ): int { + return null === $input ? 0 : (int) $input; + }, + null, + 0, + ), + 'boolean input' => array( + array( + 'type' => 'boolean', + 'description' => 'The boolean to convert to integer.', + 'required' => true, + ), + static function ( bool $input ): int { + return $input ? 1 : 0; + }, + true, + 1, + ), + 'integer input' => array( + array( + 'type' => 'integer', + 'description' => 'The integer to add 5 to.', + 'required' => true, + ), + static function ( int $input ): int { + return 5 + $input; + }, + 2, + 7, + ), + 'number input' => array( + array( + 'type' => 'number', + 'description' => 'The floating number to round.', + 'required' => true, + ), + static function ( float $input ): int { + return (int) round( $input ); + }, + 2.7, + 3, + ), + 'string input' => array( + array( + 'type' => 'string', + 'description' => 'The string to measure the length of.', + 'required' => true, + ), + static function ( string $input ): int { + return strlen( $input ); + }, + 'Hello world!', + 12, + ), + 'object input' => array( + array( + 'type' => 'object', + 'description' => 'An object containing two numbers to add.', + 'properties' => array( + 'a' => array( + 'type' => 'integer', + 'description' => 'First number.', + 'required' => true, + ), + 'b' => array( + 'type' => 'integer', + 'description' => 'Second number.', + 'required' => true, + ), + ), + 'additionalProperties' => false, + ), + static function ( array $input ): int { + return $input['a'] + $input['b']; + }, + array( + 'a' => 2, + 'b' => 3, + ), + 5, + ), + 'array input' => array( + array( + 'type' => 'array', + 'description' => 'An array containing two numbers to add.', + 'required' => true, + 'minItems' => 2, + 'maxItems' => 2, + 'items' => array( + 'type' => 'integer', + ), + ), + static function ( array $input ): int { + return $input[0] + $input[1]; + }, + array( 2, 3 ), + 5, + ), + ); + } + + /** + * Tests the execution of the ability. + * + * @ticket 64098 + * + * @dataProvider data_execute_input + * + * @param array $input_schema The input schema for the ability. + * @param callable $execute_callback The execute callback for the ability. + * @param mixed $input The input to pass to the execute method. + * @param mixed $result The expected result from the execute method. + */ + public function test_execute_input( $input_schema, $execute_callback, $input, $result ) { + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => $input_schema, + 'execute_callback' => $execute_callback, + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( $result, $ability->execute( $input ) ); + } + + /** + * A static method to be used as a callback in tests. + * + * @param string $input An input string. + * @return int The length of the input string. + */ + public static function my_static_execute_callback( string $input ): int { + return strlen( $input ); + } + + /** + * An instance method to be used as a callback in tests. + * + * @param string $input An input string. + * @return int The length of the input string. + */ + public function my_instance_execute_callback( string $input ): int { + return strlen( $input ); + } + + /** + * Data provider for testing different types of execute callbacks. + * + * @return array Data sets with different execute callbacks. + */ + public function data_execute_callback() { + return array( + 'function name string' => array( + 'strlen', + ), + 'closure' => array( + static function ( string $input ): int { + return strlen( $input ); + }, + ), + 'static class method string' => array( + 'Tests_Abilities_API_WpAbility::my_static_execute_callback', + ), + 'static class method array' => array( + array( 'Tests_Abilities_API_WpAbility', 'my_static_execute_callback' ), + ), + 'object method' => array( + array( $this, 'my_instance_execute_callback' ), + ), + ); + } + + /** + * Tests the execution of the ability with different types of callbacks. + * + * @ticket 64098 + * + * @dataProvider data_execute_callback + * + * @param callable $execute_callback The execute callback to test. + */ + public function test_execute_with_different_callbacks( $execute_callback ) { + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'string', + 'description' => 'Test input string.', + 'required' => true, + ), + 'execute_callback' => $execute_callback, + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( 6, $ability->execute( 'hello!' ) ); + } + + /** + * Tests the execution of the ability with no input. + * + * @ticket 64098 + */ + public function test_execute_no_input() { + $args = array_merge( + self::$test_ability_properties, + array( + 'execute_callback' => static function (): int { + return 42; + }, + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( 42, $ability->execute() ); + } + + /** + * Tests that before_execute_ability action is fired with correct parameters. + * + * @ticket 64098 + */ + public function test_before_execute_ability_action() { + $action_ability_name = null; + $action_input = null; + + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'integer', + 'description' => 'Test input parameter.', + 'required' => true, + ), + 'execute_callback' => static function ( int $input ): int { + return $input * 2; + }, + ) + ); + + $callback = static function ( $ability_name, $input ) use ( &$action_ability_name, &$action_input ) { + $action_ability_name = $ability_name; + $action_input = $input; + }; + + add_action( 'wp_before_execute_ability', $callback, 10, 2 ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute( 5 ); + + remove_action( 'wp_before_execute_ability', $callback ); + + $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); + $this->assertSame( 5, $action_input, 'Action should receive correct input' ); + $this->assertSame( 10, $result, 'Ability should execute correctly' ); + } + + /** + * Tests that before_execute_ability action is fired with null input when no input schema is defined. + * + * @ticket 64098 + */ + public function test_before_execute_ability_action_no_input() { + $action_ability_name = null; + $action_input = null; + + $args = array_merge( + self::$test_ability_properties, + array( + 'execute_callback' => static function (): int { + return 42; + }, + ) + ); + + $callback = static function ( $ability_name, $input ) use ( &$action_ability_name, &$action_input ) { + $action_ability_name = $ability_name; + $action_input = $input; + }; + + add_action( 'wp_before_execute_ability', $callback, 10, 2 ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + remove_action( 'wp_before_execute_ability', $callback ); + + $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); + $this->assertNull( $action_input, 'Action should receive null input when no input provided' ); + $this->assertSame( 42, $result, 'Ability should execute correctly' ); + } + + /** + * Tests that after_execute_ability action is fired with correct parameters. + * + * @ticket 64098 + */ + public function test_after_execute_ability_action() { + $action_ability_name = null; + $action_input = null; + $action_result = null; + + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'integer', + 'description' => 'Test input parameter.', + 'required' => true, + ), + 'execute_callback' => static function ( int $input ): int { + return $input * 3; + }, + ) + ); + + $callback = static function ( $ability_name, $input, $result ) use ( &$action_ability_name, &$action_input, &$action_result ) { + $action_ability_name = $ability_name; + $action_input = $input; + $action_result = $result; + }; + + add_action( 'wp_after_execute_ability', $callback, 10, 3 ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute( 7 ); + + remove_action( 'wp_after_execute_ability', $callback ); + + $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); + $this->assertSame( 7, $action_input, 'Action should receive correct input' ); + $this->assertSame( 21, $action_result, 'Action should receive correct result' ); + $this->assertSame( 21, $result, 'Ability should execute correctly' ); + } + + /** + * Tests that after_execute_ability action is fired with null input when no input schema is defined. + * + * @ticket 64098 + */ + public function test_after_execute_ability_action_no_input() { + $action_ability_name = null; + $action_input = null; + $action_result = null; + + $args = array_merge( + self::$test_ability_properties, + array( + 'output_schema' => array(), + 'execute_callback' => static function (): string { + return 'test-result'; + }, + ) + ); + + $callback = static function ( $ability_name, $input, $result ) use ( &$action_ability_name, &$action_input, &$action_result ) { + $action_ability_name = $ability_name; + $action_input = $input; + $action_result = $result; + }; + + add_action( 'wp_after_execute_ability', $callback, 10, 3 ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + remove_action( 'wp_after_execute_ability', $callback ); + + $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); + $this->assertNull( $action_input, 'Action should receive null input when no input provided' ); + $this->assertSame( 'test-result', $action_result, 'Action should receive correct result' ); + $this->assertSame( 'test-result', $result, 'Ability should execute correctly' ); + } + + /** + * Tests that neither action is fired when execution fails due to permission issues. + * + * @ticket 64098 + */ + public function test_actions_not_fired_on_permission_failure() { + $before_action_fired = false; + $after_action_fired = false; + + $args = array_merge( + self::$test_ability_properties, + array( + 'permission_callback' => static function (): bool { + return false; + }, + ) + ); + + $before_callback = static function () use ( &$before_action_fired ) { + $before_action_fired = true; + }; + + $after_callback = static function () use ( &$after_action_fired ) { + $after_action_fired = true; + }; + + add_action( 'wp_before_execute_ability', $before_callback ); + add_action( 'wp_after_execute_ability', $after_callback ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + remove_action( 'wp_before_execute_ability', $before_callback ); + remove_action( 'wp_after_execute_ability', $after_callback ); + + $this->assertFalse( $before_action_fired, 'before_execute_ability action should not be fired on permission failure' ); + $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired on permission failure' ); + $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error on permission failure' ); + } + + /** + * Tests that after_execute_ability action is not fired when execution callback returns WP_Error. + * + * @ticket 64098 + */ + public function test_after_action_not_fired_on_execution_error() { + $before_action_fired = false; + $after_action_fired = false; + + $args = array_merge( + self::$test_ability_properties, + array( + 'execute_callback' => static function () { + return new WP_Error( 'test_error', 'Test execution error' ); + }, + ) + ); + + $before_callback = static function () use ( &$before_action_fired ) { + $before_action_fired = true; + }; + + $after_callback = static function () use ( &$after_action_fired ) { + $after_action_fired = true; + }; + + add_action( 'wp_before_execute_ability', $before_callback ); + add_action( 'wp_after_execute_ability', $after_callback ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + remove_action( 'wp_before_execute_ability', $before_callback ); + remove_action( 'wp_after_execute_ability', $after_callback ); + + $this->assertTrue( $before_action_fired, 'before_execute_ability action should be fired even if execution fails' ); + $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when execution returns WP_Error' ); + $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error from execution callback' ); + } + + /** + * Tests that after_execute_ability action is not fired when output validation fails. + * + * @ticket 64098 + */ + public function test_after_action_not_fired_on_output_validation_error() { + $before_action_fired = false; + $after_action_fired = false; + + $args = array_merge( + self::$test_ability_properties, + array( + 'output_schema' => array( + 'type' => 'string', + 'description' => 'Expected string output.', + 'required' => true, + ), + 'execute_callback' => static function (): int { + return 42; + }, + ) + ); + + $before_callback = static function () use ( &$before_action_fired ) { + $before_action_fired = true; + }; + + $after_callback = static function () use ( &$after_action_fired ) { + $after_action_fired = true; + }; + + add_action( 'wp_before_execute_ability', $before_callback ); + add_action( 'wp_after_execute_ability', $after_callback ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + remove_action( 'wp_before_execute_ability', $before_callback ); + remove_action( 'wp_after_execute_ability', $after_callback ); + + $this->assertTrue( $before_action_fired, 'before_execute_ability action should be fired even if output validation fails' ); + $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when output validation fails' ); + $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error for output validation failure' ); + } +} diff --git a/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php new file mode 100644 index 0000000000000..c517ad66a60fd --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php @@ -0,0 +1,718 @@ + + */ + private $doing_it_wrong_log = array(); + + /** + * Set up before each test. + */ + public function set_up(): void { + parent::set_up(); + + $this->registry = new WP_Ability_Categories_Registry(); + $this->doing_it_wrong_log = array(); + + add_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ), 10, 3 ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + remove_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ) ); + $this->doing_it_wrong_log = array(); + + $this->registry = null; + + parent::tear_down(); + } + + /** + * Records `_doing_it_wrong` calls for later assertions. + * + * @param string $the_method Function name flagged by `_doing_it_wrong`. + * @param string $message Message supplied to `_doing_it_wrong`. + * @param string $version Version string supplied to `_doing_it_wrong`. + */ + public function record_doing_it_wrong( string $the_method, string $message, string $version ): void { + $this->doing_it_wrong_log[] = array( + 'function' => $the_method, + 'message' => $message, + 'version' => $version, + ); + } + + /** + * Asserts that `_doing_it_wrong` was triggered for the expected function. + * + * @param string $the_method Function name expected to trigger `_doing_it_wrong`. + * @param string|null $message_contains Optional. String that should be contained in the error message. + */ + private function assertDoingItWrongTriggered( string $the_method, ?string $message_contains = null ): void { + foreach ( $this->doing_it_wrong_log as $entry ) { + if ( $the_method === $entry['function'] ) { + // If message check is specified, verify it contains the expected text. + if ( null !== $message_contains && false === strpos( $entry['message'], $message_contains ) ) { + continue; + } + return; + } + } + + if ( null !== $message_contains ) { + $this->fail( + sprintf( + 'Failed asserting that _doing_it_wrong() was triggered for %s with message containing "%s".', + $the_method, + $message_contains + ) + ); + } else { + $this->fail( sprintf( 'Failed asserting that _doing_it_wrong() was triggered for %s.', $the_method ) ); + } + } + + /** + * Test registering a valid category. + * + * @ticket 64098 + */ + public function test_register_valid_category(): void { + $result = $this->registry->register( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'test-math', $result->get_slug() ); + $this->assertSame( 'Math', $result->get_label() ); + $this->assertSame( 'Mathematical operations.', $result->get_description() ); + } + + /** + * Test registering category with invalid slug format. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register + */ + public function test_register_category_invalid_slug_format(): void { + // Uppercase characters not allowed. + $result = $this->registry->register( + 'Test-Math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'slug must contain only lowercase' ); + } + + /** + * Test registering category with invalid slug - underscore. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register + */ + public function test_register_category_invalid_slug_underscore(): void { + $result = $this->registry->register( + 'test_math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'slug must contain only lowercase' ); + } + + /** + * Test registering category without label. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register + */ + public function test_register_category_missing_label(): void { + $result = $this->registry->register( + 'test-math', + array( + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); + } + + /** + * Test registering category without description. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register + */ + public function test_register_category_missing_description(): void { + $result = $this->registry->register( + 'test-math', + array( + 'label' => 'Math', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); + } + + /** + * Test registering duplicate category. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register + */ + public function test_register_duplicate_category(): void { + $result = $this->registry->register( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + + $result = $this->registry->register( + 'test-math', + array( + 'label' => 'Math 2', + 'description' => 'Another math category.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'already registered' ); + } + + /** + * Test unregistering existing category. + * + * @ticket 64098 + */ + public function test_unregister_existing_category(): void { + $this->registry->register( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $result = $this->registry->unregister( 'test-math' ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); + } + + /** + * Test unregistering non-existent category. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::unregister + */ + public function test_unregister_nonexistent_category(): void { + $result = $this->registry->unregister( 'test-nonexistent' ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::unregister' ); + } + + /** + * Test retrieving existing category. + * + * @ticket 64098 + */ + public function test_get_existing_category(): void { + $this->registry->register( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $result = $this->registry->get_registered( 'test-math' ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'test-math', $result->get_slug() ); + } + + /** + * Test retrieving non-existent category. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_registered + */ + public function test_get_nonexistent_category(): void { + $result = $this->registry->get_registered( 'test-nonexistent' ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::get_registered' ); + } + + /** + * Tests checking if an ability category is registered. + * + * @ticket 64098 + */ + public function test_has_registered_ability_category(): void { + $category_slug = 'test-math'; + $this->registry->register( + $category_slug, + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $result = $this->registry->is_registered( $category_slug ); + + $this->assertTrue( $result ); + } + + /** + * Tests checking if a non-existent ability category is registered. + * + * @ticket 64098 + */ + public function test_has_registered_nonexistent_ability_category(): void { + $result = $this->registry->is_registered( 'test/non-existent' ); + + $this->assertFalse( $result ); + } + + /** + * Test retrieving all registered categories. + * + * @ticket 64098 + */ + public function test_get_all_categories(): void { + $this->registry->register( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->registry->register( + 'test-system', + array( + 'label' => 'System', + 'description' => 'System operations.', + ) + ); + + $categories = $this->registry->get_all_registered(); + + $this->assertIsArray( $categories ); + $this->assertCount( 2, $categories ); + $this->assertArrayHasKey( 'test-math', $categories ); + $this->assertArrayHasKey( 'test-system', $categories ); + } + + /** + * Test category is_registered method. + * + * @ticket 64098 + */ + public function test_category_is_registered(): void { + $this->assertFalse( $this->registry->is_registered( 'test-math' ) ); + + $this->registry->register( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertTrue( $this->registry->is_registered( 'test-math' ) ); + } + + /** + * Test category with special characters in label and description. + * + * @ticket 64098 + */ + public function test_category_with_special_characters(): void { + $result = $this->registry->register( + 'test-special', + array( + 'label' => 'Math & Science ', + 'description' => 'Operations with "quotes" and \'apostrophes\'.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'Math & Science ', $result->get_label() ); + $this->assertSame( 'Operations with "quotes" and \'apostrophes\'.', $result->get_description() ); + } + + /** + * Data provider for valid ability category slugs. + * + * @return array> Valid ability category slugs. + */ + public function data_valid_slug_provider(): array { + return array( + array( 'test-simple' ), + array( 'test-multiple-words' ), + array( 'test-with-numbers-123' ), + array( 'test-a' ), + array( 'test-123' ), + ); + } + + /** + * Test category slug validation with valid formats. + * + * @ticket 64098 + * + * @dataProvider data_valid_slug_provider + * + * @param string $slug The category slug to test. + */ + public function test_category_slug_valid_formats( string $slug ): void { + $result = $this->registry->register( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result, "Slug '{$slug}' should be valid" ); + } + + /** + * Data provider for invalid ability category slugs. + * + * @return array> Invalid ability category slugs. + */ + public function data_invalid_slug_provider(): array { + return array( + array( 'Test-Uppercase' ), + array( 'test_underscore' ), + array( 'test.dot' ), + array( 'test/slash' ), + array( 'test space' ), + array( '-test-start-dash' ), + array( 'test-end-dash-' ), + array( 'test--double-dash' ), + ); + } + + /** + * Test category slug validation with invalid formats. + * + * @ticket 64098 + * + * @dataProvider data_invalid_slug_provider + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register + * + * @param string $slug The category slug to test. + */ + public function test_category_slug_invalid_formats( string $slug ): void { + $result = $this->registry->register( + $slug, + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->assertNull( $result, "Slug '{$slug}' should be invalid" ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); + } + + /** + * Test registering category with non-string label. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register + */ + public function test_category_constructor_non_string_label(): void { + $result = $this->registry->register( + 'test-invalid', + array( + 'label' => 123, // Integer instead of string + 'description' => 'Valid description.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); + } + + /** + * Test registering category with empty label. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register + */ + public function test_category_constructor_empty_label(): void { + $result = $this->registry->register( + 'test-invalid', + array( + 'label' => '', + 'description' => 'Valid description.', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); + } + + /** + * Test registering category with non-string description. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register + */ + public function test_category_constructor_non_string_description(): void { + $result = $this->registry->register( + 'test-invalid', + array( + 'label' => 'Valid Label', + 'description' => array( 'invalid' ), // Array instead of string + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); + } + + /** + * Test registering category with empty description. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register + */ + public function test_category_constructor_empty_description(): void { + $result = $this->registry->register( + 'test-invalid', + array( + 'label' => 'Valid Label', + 'description' => '', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' ); + } + + /** + * Test register_ability_category_args filter. + * + * @ticket 64098 + */ + public function test_register_category_args_filter(): void { + add_filter( + 'wp_register_ability_category_args', + static function ( $args, $slug ) { + if ( 'test-filtered' === $slug ) { + $args['label'] = 'Filtered Label'; + $args['description'] = 'Filtered Description'; + } + return $args; + }, + 10, + 2 + ); + + $result = $this->registry->register( + 'test-filtered', + array( + 'label' => 'Original Label', + 'description' => 'Original Description.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'Filtered Label', $result->get_label() ); + $this->assertSame( 'Filtered Description', $result->get_description() ); + } + + /** + * Test that WP_Ability_Category cannot be unserialized. + * + * @ticket 64098 + */ + public function test_category_wakeup_throws_exception(): void { + $category = $this->registry->register( + 'test-serialize', + array( + 'label' => 'Test', + 'description' => 'Test description.', + ) + ); + + $this->expectException( LogicException::class ); + $serialized = serialize( $category ); + unserialize( $serialized ); + } + + /** + * Test registering a category with valid meta. + * + * @ticket 64098 + */ + public function test_register_category_with_valid_meta(): void { + $meta = array( + 'icon' => 'dashicons-calculator', + 'priority' => 10, + 'custom' => array( 'key' => 'value' ), + ); + + $result = $this->registry->register( + 'test-meta', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'meta' => $meta, + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( 'test-meta', $result->get_slug() ); + $this->assertSame( $meta, $result->get_meta() ); + } + + /** + * Test registering a category with empty meta array. + * + * @ticket 64098 + */ + public function test_register_category_with_empty_meta(): void { + $result = $this->registry->register( + 'test-empty-meta', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'meta' => array(), + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( array(), $result->get_meta() ); + } + + /** + * Test registering a category without meta returns empty array. + * + * @ticket 64098 + */ + public function test_register_category_without_meta_returns_empty_array(): void { + $result = $this->registry->register( + 'test-no-meta', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( array(), $result->get_meta() ); + } + + /** + * Test registering a category with invalid meta (non-array). + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::register + */ + public function test_register_category_with_invalid_meta(): void { + $result = $this->registry->register( + 'test-invalid-meta', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'meta' => 'invalid-string', + ) + ); + + $this->assertNull( $result ); + $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'valid `meta` array' ); + } + + /** + * Test registering a category with unknown property triggers _doing_it_wrong. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Category::__construct + */ + public function test_register_category_with_unknown_property(): void { + $result = $this->registry->register( + 'test-unknown-property', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'unknown_property' => 'some value', + ) + ); + + // Category should still be created. + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + // But _doing_it_wrong should be triggered. + $this->assertDoingItWrongTriggered( 'WP_Ability_Category::__construct', 'not a valid property' ); + } + + /** + * Test category registry singleton. + * + * @ticket 64098 + */ + public function test_category_registry_singleton(): void { + $instance1 = WP_Ability_Categories_Registry::get_instance(); + $instance2 = WP_Ability_Categories_Registry::get_instance(); + + $this->assertSame( $instance1, $instance2 ); + } +} diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php new file mode 100644 index 0000000000000..21b108f5ac8f9 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -0,0 +1,652 @@ + 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + + self::$test_ability_args = array( + 'label' => 'Add numbers', + 'description' => 'Calculates the result of adding two numbers.', + 'category' => 'math', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'a' => array( + 'type' => 'number', + 'description' => 'First number.', + 'required' => true, + ), + 'b' => array( + 'type' => 'number', + 'description' => 'Second number.', + 'required' => true, + ), + ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'number', + 'description' => 'The result of adding the two numbers.', + 'required' => true, + ), + 'execute_callback' => static function ( array $input ): int { + return $input['a'] + $input['b']; + }, + 'permission_callback' => static function (): bool { + return true; + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + ), + 'show_in_rest' => true, + ), + ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + foreach ( wp_get_abilities() as $ability ) { + if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) { + continue; + } + + wp_unregister_ability( $ability->get_name() ); + } + + // Clean up registered test ability category. + wp_unregister_ability_category( 'math' ); + + parent::tear_down(); + } + + /** + * Tests registering an ability with invalid name. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_invalid_name(): void { + do_action( 'wp_abilities_api_init' ); + + $result = wp_register_ability( 'invalid_name', array() ); + + $this->assertNull( $result ); + } + + /** + * Tests registering an ability when `abilities_api_init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage wp_register_ability + */ + public function test_register_ability_no_abilities_api_init_action(): void { + global $wp_actions; + + // Store the original action count. + $original_count = isset( $wp_actions['wp_abilities_api_init'] ) ? $wp_actions['wp_abilities_api_init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['wp_abilities_api_init'] ); + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['wp_abilities_api_init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Tests registering an ability when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_register_ability_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Tests registering a valid ability. + * + * @ticket 64098 + */ + public function test_register_valid_ability(): void { + do_action( 'wp_abilities_api_init' ); + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + $expected_annotations = array_merge( + self::$test_ability_args['meta']['annotations'], + array( + 'idempotent' => false, + ) + ); + $expected_meta = array_merge( + self::$test_ability_args['meta'], + array( + 'annotations' => $expected_annotations, + 'show_in_rest' => true, + ) + ); + + $this->assertInstanceOf( WP_Ability::class, $result ); + $this->assertSame( self::$test_ability_name, $result->get_name() ); + $this->assertSame( self::$test_ability_args['label'], $result->get_label() ); + $this->assertSame( self::$test_ability_args['description'], $result->get_description() ); + $this->assertSame( self::$test_ability_args['input_schema'], $result->get_input_schema() ); + $this->assertSame( self::$test_ability_args['output_schema'], $result->get_output_schema() ); + $this->assertEquals( $expected_meta, $result->get_meta() ); + $this->assertTrue( + $result->check_permissions( + array( + 'a' => 2, + 'b' => 3, + ) + ) + ); + $this->assertSame( + 5, + $result->execute( + array( + 'a' => 2, + 'b' => 3, + ) + ) + ); + } + + /** + * Tests executing an ability with no permissions. + * + * @ticket 64098 + */ + public function test_register_ability_no_permissions(): void { + do_action( 'wp_abilities_api_init' ); + + self::$test_ability_args['permission_callback'] = static function (): bool { + return false; + }; + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + $this->assertFalse( + $result->check_permissions( + array( + 'a' => 2, + 'b' => 3, + ) + ) + ); + + $actual = $result->execute( + array( + 'a' => 2, + 'b' => 3, + ) + ); + $this->assertWPError( + $actual, + 'Execution should fail due to no permissions' + ); + $this->assertEquals( 'ability_invalid_permissions', $actual->get_error_code() ); + } + + /** + * Tests registering an ability with a custom ability class. + * + * @ticket 64098 + */ + public function test_register_ability_custom_ability_class(): void { + do_action( 'wp_abilities_api_init' ); + + $result = wp_register_ability( + self::$test_ability_name, + array_merge( + self::$test_ability_args, + array( + 'ability_class' => Mock_Custom_Ability::class, + ) + ) + ); + + $this->assertInstanceOf( Mock_Custom_Ability::class, $result ); + $this->assertSame( + 9999, + $result->execute( + array( + 'a' => 2, + 'b' => 3, + ) + ) + ); + + // Try again with an invalid class throws a doing it wrong. + $this->setExpectedIncorrectUsage( WP_Abilities_Registry::class . '::register' ); + wp_register_ability( + self::$test_ability_name, + array_merge( + self::$test_ability_args, + array( + 'ability_class' => 'Non_Existent_Class', + ) + ) + ); + } + + /** + * Tests executing an ability with input not matching schema. + * + * @ticket 64098 + */ + public function test_execute_ability_no_input_schema_match(): void { + do_action( 'wp_abilities_api_init' ); + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + $actual = $result->execute( + array( + 'a' => 2, + 'b' => 3, + 'unknown' => 1, + ) + ); + + $this->assertWPError( + $actual, + 'Execution should fail due to input not matching schema.' + ); + $this->assertSame( 'ability_invalid_input', $actual->get_error_code() ); + $this->assertSame( + 'Ability "test/add-numbers" has invalid input. Reason: unknown is not a valid property of Object.', + $actual->get_error_message() + ); + } + + /** + * Tests executing an ability with output not matching schema. + * + * @ticket 64098 + */ + public function test_execute_ability_no_output_schema_match(): void { + do_action( 'wp_abilities_api_init' ); + + self::$test_ability_args['execute_callback'] = static function (): bool { + return true; + }; + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + $actual = $result->execute( + array( + 'a' => 2, + 'b' => 3, + ) + ); + $this->assertWPError( + $actual, + 'Execution should fail due to output not matching schema.' + ); + $this->assertSame( 'ability_invalid_output', $actual->get_error_code() ); + $this->assertSame( + 'Ability "test/add-numbers" has invalid output. Reason: output is not of type number.', + $actual->get_error_message() + ); + } + + /** + * Tests input validation failing due to schema mismatch. + * + * @ticket 64098 + */ + public function test_validate_input_no_input_schema_match(): void { + do_action( 'wp_abilities_api_init' ); + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + $actual = $result->validate_input( + array( + 'a' => 2, + 'b' => 3, + 'unknown' => 1, + ) + ); + + $this->assertWPError( + $actual, + 'Input validation should fail due to input not matching schema.' + ); + $this->assertSame( 'ability_invalid_input', $actual->get_error_code() ); + $this->assertSame( + 'Ability "test/add-numbers" has invalid input. Reason: unknown is not a valid property of Object.', + $actual->get_error_message() + ); + } + + /** + * Tests permission callback receiving input for contextual permission checks. + * + * @ticket 64098 + */ + public function test_permission_callback_receives_input(): void { + do_action( 'wp_abilities_api_init' ); + + $received_input = null; + self::$test_ability_args['permission_callback'] = static function ( array $input ) use ( &$received_input ): bool { + $received_input = $input; + // Allow only if 'a' is greater than 'b' + return $input['a'] > $input['b']; + }; + + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + // Test with a > b (should be allowed) + $this->assertTrue( + $result->check_permissions( + array( + 'a' => 5, + 'b' => 3, + ) + ) + ); + $this->assertSame( + array( + 'a' => 5, + 'b' => 3, + ), + $received_input + ); + + // Test with a < b (should be denied) + $this->assertFalse( + $result->check_permissions( + array( + 'a' => 2, + 'b' => 8, + ) + ) + ); + $this->assertSame( + array( + 'a' => 2, + 'b' => 8, + ), + $received_input + ); + } + + /** + * Tests unregistering an ability when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_unregister_ability_no_init_action(): void { + global $wp_actions; + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_unregister_ability( self::$test_ability_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Tests unregistering existing ability. + * + * @ticket 64098 + */ + public function test_unregister_existing_ability() { + do_action( 'wp_abilities_api_init' ); + + wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + $result = wp_unregister_ability( self::$test_ability_name ); + + $this->assertEquals( + new WP_Ability( self::$test_ability_name, self::$test_ability_args ), + $result + ); + } + + /** + * Tests retrieving an ability when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_get_ability_no_init_action(): void { + global $wp_actions; + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_get_ability( self::$test_ability_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Tests retrieving existing ability registered with the `wp_abilities_api_init` callback. + * + * @ticket 64098 + */ + public function test_get_existing_ability_using_callback() { + $name = self::$test_ability_name; + $args = self::$test_ability_args; + $callback = static function ( $instance ) use ( $name, $args ) { + wp_register_ability( $name, $args ); + }; + + add_action( 'wp_abilities_api_init', $callback ); + + // Reset the Registry, to ensure it's empty before the test. + $registry_reflection = new ReflectionClass( WP_Abilities_Registry::class ); + $instance_prop = $registry_reflection->getProperty( 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance_prop->setAccessible( true ); + } + $instance_prop->setValue( null, null ); + + $result = wp_get_ability( $name ); + + remove_action( 'wp_abilities_api_init', $callback ); + + $this->assertEquals( + new WP_Ability( $name, $args ), + $result, + 'Ability does not share expected properties.' + ); + } + + /** + * Tests checking if an ability is registered when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_has_ability_no_init_action(): void { + global $wp_actions; + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_has_ability( self::$test_ability_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertFalse( $result ); + } + + /** + * Tests checking if an ability is registered. + * + * @ticket 64098 + */ + public function test_has_registered_ability() { + do_action( 'wp_abilities_api_init' ); + + wp_register_ability( self::$test_ability_name, self::$test_ability_args ); + + $result = wp_has_ability( self::$test_ability_name ); + + $this->assertTrue( $result ); + } + + /** + * Tests checking if a non-existent ability is registered. + * + * @ticket 64098 + */ + public function test_has_registered_nonexistent_ability() { + do_action( 'wp_abilities_api_init' ); + + $result = wp_has_ability( 'test/non-existent' ); + + $this->assertFalse( $result ); + } + + /** + * Tests retrieving all registered abilities when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_instance + */ + public function test_get_abilities_no_init_action(): void { + global $wp_actions; + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_get_abilities(); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertSame( array(), $result ); + } + + /** + * Tests retrieving all registered abilities. + * + * @ticket 64098 + */ + public function test_get_all_registered_abilities() { + do_action( 'wp_abilities_api_init' ); + + $ability_one_name = 'test/ability-one'; + $ability_one_args = self::$test_ability_args; + wp_register_ability( $ability_one_name, $ability_one_args ); + + $ability_two_name = 'test/ability-two'; + $ability_two_args = self::$test_ability_args; + wp_register_ability( $ability_two_name, $ability_two_args ); + + $ability_three_name = 'test/ability-three'; + $ability_three_args = self::$test_ability_args; + wp_register_ability( $ability_three_name, $ability_three_args ); + + $expected = array( + $ability_one_name => new WP_Ability( $ability_one_name, $ability_one_args ), + $ability_two_name => new WP_Ability( $ability_two_name, $ability_two_args ), + $ability_three_name => new WP_Ability( $ability_three_name, $ability_three_args ), + ); + + $result = wp_get_abilities(); + $this->assertEquals( $expected, $result ); + } +} diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php b/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php new file mode 100644 index 0000000000000..ede55f98eaeaf --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php @@ -0,0 +1,365 @@ + 'Math', + 'description' => 'Mathematical operations.', + ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + // Clean up any test ability categories registered during tests. + foreach ( wp_get_ability_categories() as $ability_category ) { + if ( ! str_starts_with( $ability_category->get_slug(), 'test-' ) ) { + continue; + } + + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + parent::tear_down(); + } + + /** + * Test registering ability category before `abilities_api_categories_init` hook. + * + * @ticket 64098 + * + * @expectedIncorrectUsage wp_register_ability_category + */ + public function test_register_category_before_init_hook(): void { + $result = wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + $this->assertNull( $result ); + } + + /** + * Tests registering an ability category when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_register_ability_category_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Test registering a valid ability category. + * + * @ticket 64098 + */ + public function test_register_valid_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $result = wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( self::$test_ability_category_name, $result->get_slug() ); + $this->assertSame( 'Math', $result->get_label() ); + $this->assertSame( 'Mathematical operations.', $result->get_description() ); + } + + /** + * Tests unregistering an ability category when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_unregister_ability_category_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_unregister_ability_category( self::$test_ability_category_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Test unregistering non-existent ability category. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::unregister + */ + public function test_unregister_nonexistent_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $result = wp_unregister_ability_category( 'test-nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * Test unregistering existing ability category. + * + * @ticket 64098 + */ + public function test_unregister_existing_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + $result = wp_unregister_ability_category( self::$test_ability_category_name ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertFalse( wp_has_ability_category( self::$test_ability_category_name ) ); + } + + /** + * Tests checking if an ability category is registered when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_has_ability_category_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_has_ability_category( self::$test_ability_category_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertFalse( $result ); + } + + /** + * Tests checking if a non-existent ability category is registered. + * + * @ticket 64098 + */ + public function test_has_registered_nonexistent_ability_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $result = wp_has_ability_category( 'test/non-existent' ); + + $this->assertFalse( $result ); + } + + /** + * Tests checking if an ability category is registered. + * + * @ticket 64098 + */ + public function test_has_registered_ability_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $category_slug = self::$test_ability_category_name; + + wp_register_ability_category( + $category_slug, + self::$test_ability_category_args + ); + + $result = wp_has_ability_category( $category_slug ); + + $this->assertTrue( $result ); + } + + /** + * Tests retrieving an ability category when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_get_ability_category_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_get_ability_category( self::$test_ability_category_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertNull( $result ); + } + + /** + * Test retrieving non-existent ability category. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_registered + */ + public function test_get_nonexistent_category(): void { + do_action( 'wp_abilities_api_categories_init' ); + + $result = wp_get_ability_category( 'test-nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * Test retrieving existing ability category registered with the `wp_abilities_api_categories_init` callback. + * + * @ticket 64098 + */ + public function test_get_existing_category_using_callback(): void { + $name = self::$test_ability_category_name; + $args = self::$test_ability_category_args; + $callback = static function ( $instance ) use ( $name, $args ) { + wp_register_ability_category( $name, $args ); + }; + + add_action( 'wp_abilities_api_categories_init', $callback ); + + // Reset the Registry, to ensure it's empty before the test. + $registry_reflection = new ReflectionClass( WP_Ability_Categories_Registry::class ); + $instance_prop = $registry_reflection->getProperty( 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance_prop->setAccessible( true ); + } + $instance_prop->setValue( null, null ); + + $result = wp_get_ability_category( $name ); + + remove_action( 'wp_abilities_api_categories_init', $callback ); + + $this->assertInstanceOf( WP_Ability_Category::class, $result ); + $this->assertSame( self::$test_ability_category_name, $result->get_slug() ); + } + + /** + * Test retrieving all registered ability categories when `init` action has not fired. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance + */ + public function test_get_ability_categories_no_init_action(): void { + global $wp_actions; + + do_action( 'wp_abilities_api_categories_init' ); + + // Store the original action count. + $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0; + + // Reset the action count to simulate it not being fired. + unset( $wp_actions['init'] ); + + $result = wp_get_ability_categories( self::$test_ability_category_name ); + + // Restore the original action count. + if ( $original_count > 0 ) { + $wp_actions['init'] = $original_count; + } + + $this->assertSame( array(), $result ); + } + + /** + * Test retrieving all registered ability categories. + * + * @ticket 64098 + */ + public function test_get_all_categories(): void { + do_action( 'wp_abilities_api_categories_init' ); + + wp_register_ability_category( + self::$test_ability_category_name, + self::$test_ability_category_args + ); + + wp_register_ability_category( + 'test-system', + array( + 'label' => 'System', + 'description' => 'System operations.', + ) + ); + + $categories = wp_get_ability_categories(); + + $this->assertIsArray( $categories ); + $this->assertCount( 2, $categories ); + $this->assertArrayHasKey( self::$test_ability_category_name, $categories ); + $this->assertArrayHasKey( 'test-system', $categories ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 7f8de5f0dd83d..beaf294d09e3d 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -195,6 +195,10 @@ public function test_expected_routes_in_schema() { '/wp/v2/font-families/(?P[\d]+)/font-faces', '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)', '/wp/v2/font-families/(?P[\d]+)', + '/wp-abilities/v1', + '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+?)/run', + '/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\-\/]+)', + '/wp-abilities/v1/abilities', ); $this->assertSameSets( $expected_routes, $routes ); @@ -205,7 +209,8 @@ private function is_builtin_route( $route ) { '/' === $route || preg_match( '#^/oembed/1\.0(/.+)?$#', $route ) || preg_match( '#^/wp/v2(/.+)?$#', $route ) || - preg_match( '#^/wp-site-health/v1(/.+)?$#', $route ) + preg_match( '#^/wp-site-health/v1(/.+)?$#', $route ) || + preg_match( '#^/wp-abilities/v1(/.+)?$#', $route ) ); } diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php new file mode 100644 index 0000000000000..a6e5d92f270b7 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -0,0 +1,761 @@ +user->create( + array( + 'role' => 'subscriber', + ) + ); + + // Fire the init hook to allow test ability categories registration. + do_action( 'wp_abilities_api_categories_init' ); + self::register_test_categories(); + } + + /** + * Tear down after class. + */ + public static function tear_down_after_class(): void { + // Clean up registered test ability categories. + foreach ( array( 'math', 'system', 'general' ) as $slug ) { + wp_unregister_ability_category( $slug ); + } + + parent::tear_down_after_class(); + } + + /** + * Set up before each test. + */ + public function set_up(): void { + parent::set_up(); + + // Set up REST server + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + + do_action( 'rest_api_init' ); + + // Initialize Abilities API. + do_action( 'wp_abilities_api_init' ); + $this->register_test_abilities(); + + // Set default user for tests + wp_set_current_user( self::$user_id ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + // Clean up test abilities. + foreach ( wp_get_abilities() as $ability ) { + if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) { + continue; + } + + wp_unregister_ability( $ability->get_name() ); + } + + // Reset REST server + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Register test categories for testing. + */ + public static function register_test_categories(): void { + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + + wp_register_ability_category( + 'system', + array( + 'label' => 'System', + 'description' => 'System information and operations.', + ) + ); + + wp_register_ability_category( + 'general', + array( + 'label' => 'General', + 'description' => 'General purpose abilities.', + ) + ); + } + + /** + * Register test abilities for testing. + */ + private function register_test_abilities(): void { + // Register a regular ability. + wp_register_ability( + 'test/calculator', + array( + 'label' => 'Calculator', + 'description' => 'Performs basic calculations', + 'category' => 'math', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'operation' => array( + 'type' => 'string', + 'enum' => array( 'add', 'subtract', 'multiply', 'divide' ), + ), + 'a' => array( 'type' => 'number' ), + 'b' => array( 'type' => 'number' ), + ), + ), + 'output_schema' => array( + 'type' => 'number', + ), + 'execute_callback' => static function ( array $input ) { + switch ( $input['operation'] ) { + case 'add': + return $input['a'] + $input['b']; + case 'subtract': + return $input['a'] - $input['b']; + case 'multiply': + return $input['a'] * $input['b']; + case 'divide': + return 0 !== $input['b'] ? $input['a'] / $input['b'] : null; + default: + return null; + } + }, + 'permission_callback' => static function () { + return current_user_can( 'read' ); + }, + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Register a read-only ability. + wp_register_ability( + 'test/system-info', + array( + 'label' => 'System Info', + 'description' => 'Returns system information', + 'category' => 'system', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'detail_level' => array( + 'type' => 'string', + 'enum' => array( 'basic', 'full' ), + 'default' => 'basic', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'php_version' => array( 'type' => 'string' ), + 'wp_version' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => static function ( array $input ) { + $info = array( + 'php_version' => phpversion(), + 'wp_version' => get_bloginfo( 'version' ), + ); + if ( 'full' === ( $input['detail_level'] ?? 'basic' ) ) { + $info['memory_limit'] = ini_get( 'memory_limit' ); + } + return $info; + }, + 'permission_callback' => static function () { + return current_user_can( 'read' ); + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + ), + 'category' => 'system', + 'show_in_rest' => true, + ), + ) + ); + + // Ability that does not show in REST. + wp_register_ability( + 'test/not-show-in-rest', + array( + 'label' => 'Hidden from REST', + 'description' => 'It does not show in REST.', + 'category' => 'general', + 'execute_callback' => static function (): int { + return 0; + }, + 'permission_callback' => '__return_true', + ) + ); + + // Register multiple abilities for pagination testing + for ( $i = 1; $i <= 60; $i++ ) { + wp_register_ability( + "test/ability-{$i}", + array( + 'label' => "Test Ability {$i}", + 'description' => "Test ability number {$i}", + 'category' => 'general', + 'execute_callback' => static function () use ( $i ) { + return "Result from ability {$i}"; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + } + } + + /** + * Test listing all abilities. + * + * @ticket 64098 + */ + public function test_get_items(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertNotEmpty( $data ); + + $this->assertCount( 50, $data, 'First page should return exactly 50 items (default per_page)' ); + + $ability_names = wp_list_pluck( $data, 'name' ); + $this->assertContains( 'test/calculator', $ability_names ); + $this->assertContains( 'test/system-info', $ability_names ); + $this->assertNotContains( 'test/not-show-in-rest', $ability_names ); + } + + /** + * Test getting a specific ability. + * + * @ticket 64098 + */ + public function test_get_item(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 7, $data, 'Response should contain all fields.' ); + $this->assertEquals( 'test/calculator', $data['name'] ); + $this->assertEquals( 'Calculator', $data['label'] ); + $this->assertEquals( 'Performs basic calculations', $data['description'] ); + $this->assertEquals( 'math', $data['category'] ); + $this->assertArrayHasKey( 'input_schema', $data ); + $this->assertArrayHasKey( 'output_schema', $data ); + $this->assertArrayHasKey( 'meta', $data ); + $this->assertTrue( $data['meta']['show_in_rest'] ); + } + + /** + * Test getting a specific ability with only selected fields. + * + * @ticket 64098 + */ + public function test_get_item_with_selected_fields(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); + $request->set_param( '_fields', 'name,label' ); + $response = $this->server->dispatch( $request ); + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); + remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 2, $data, 'Response should only contain the requested fields.' ); + $this->assertEquals( 'test/calculator', $data['name'] ); + $this->assertEquals( 'Calculator', $data['label'] ); + } + + /** + * Test getting a specific ability with embed context. + * + * @ticket 64098 + */ + public function test_get_item_with_embed_context(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); + $request->set_param( 'context', 'embed' ); + $response = $this->server->dispatch( $request ); + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request ); + remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertCount( 3, $data, 'Response should only contain the fields for embed context.' ); + $this->assertEquals( 'test/calculator', $data['name'] ); + $this->assertEquals( 'Calculator', $data['label'] ); + $this->assertEquals( 'math', $data['category'] ); + } + + /** + * Test getting a non-existent ability returns 404. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_registered + */ + public function test_get_item_not_found(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/non/existent' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_not_found', $data['code'] ); + } + + /** + * Test getting an ability that does not show in REST returns 404. + * + * @ticket 64098 + */ + public function test_get_item_not_show_in_rest(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/not-show-in-rest' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_not_found', $data['code'] ); + } + + /** + * Test permission check for listing abilities. + * + * @ticket 64098 + */ + public function test_get_items_permission_denied(): void { + // Test with non-logged-in user + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * Test pagination headers. + * + * @ticket 64098 + */ + public function test_pagination_headers(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'per_page', 10 ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $headers = $response->get_headers(); + $this->assertArrayHasKey( 'X-WP-Total', $headers ); + $this->assertArrayHasKey( 'X-WP-TotalPages', $headers ); + + $total_abilities = count( wp_get_abilities() ) - 1; // Exclude the one that doesn't show in REST. + $this->assertEquals( $total_abilities, (int) $headers['X-WP-Total'] ); + $this->assertEquals( ceil( $total_abilities / 10 ), (int) $headers['X-WP-TotalPages'] ); + } + + /** + * Test HEAD method returns empty body with proper headers. + * + * @ticket 64098 + */ + public function test_head_request(): void { + $request = new WP_REST_Request( 'HEAD', '/wp-abilities/v1/abilities' ); + $response = $this->server->dispatch( $request ); + + // Verify empty response body + $data = $response->get_data(); + $this->assertEmpty( $data ); + + // Verify pagination headers are present + $headers = $response->get_headers(); + $this->assertArrayHasKey( 'X-WP-Total', $headers ); + $this->assertArrayHasKey( 'X-WP-TotalPages', $headers ); + } + + /** + * Test pagination links. + * + * @ticket 64098 + */ + public function test_pagination_links(): void { + // Test first page (should have 'next' link header but no 'prev') + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'per_page', 10 ); + $request->set_param( 'page', 1 ); + $response = $this->server->dispatch( $request ); + + $headers = $response->get_headers(); + $link_header = $headers['Link'] ?? ''; + + // Parse Link header for rel="next" and rel="prev" + $this->assertStringContainsString( 'rel="next"', $link_header ); + $this->assertStringNotContainsString( 'rel="prev"', $link_header ); + + // Test middle page (should have both 'next' and 'prev' link headers) + $request->set_param( 'page', 3 ); + $response = $this->server->dispatch( $request ); + + $headers = $response->get_headers(); + $link_header = $headers['Link'] ?? ''; + + $this->assertStringContainsString( 'rel="next"', $link_header ); + $this->assertStringContainsString( 'rel="prev"', $link_header ); + + // Test last page (should have 'prev' link header but no 'next') + $total_abilities = count( wp_get_abilities() ); + $last_page = ceil( $total_abilities / 10 ); + $request->set_param( 'page', $last_page ); + $response = $this->server->dispatch( $request ); + + $headers = $response->get_headers(); + $link_header = $headers['Link'] ?? ''; + + $this->assertStringNotContainsString( 'rel="next"', $link_header ); + $this->assertStringContainsString( 'rel="prev"', $link_header ); + } + + /** + * Test collection parameters. + * + * @ticket 64098 + */ + public function test_collection_params(): void { + // Test per_page parameter + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'per_page', 5 ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + $this->assertCount( 5, $data ); + + // Test page parameter + $request->set_param( 'page', 2 ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertCount( 5, $data ); + + // Verify we got different abilities on page 2 + $page1_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $page1_request->set_param( 'per_page', 5 ); + $page1_request->set_param( 'page', 1 ); + $page1_response = $this->server->dispatch( $page1_request ); + $page1_names = wp_list_pluck( $page1_response->get_data(), 'name' ); + $page2_names = wp_list_pluck( $data, 'name' ); + + $this->assertNotEquals( $page1_names, $page2_names ); + } + + /** + * Test response links for individual abilities. + * + * @ticket 64098 + */ + public function test_ability_response_links(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); + $response = $this->server->dispatch( $request ); + + $links = $response->get_links(); + $this->assertArrayHasKey( 'self', $links ); + $this->assertArrayHasKey( 'collection', $links ); + $this->assertArrayHasKey( 'wp:action-run', $links ); + + // Verify link URLs + $self_link = $links['self'][0]['href']; + $this->assertStringContainsString( '/wp-abilities/v1/abilities/test/calculator', $self_link ); + + $collection_link = $links['collection'][0]['href']; + $this->assertStringContainsString( '/wp-abilities/v1/abilities', $collection_link ); + + $run_link = $links['wp:action-run'][0]['href']; + $this->assertStringContainsString( '/wp-abilities/v1/abilities/test/calculator/run', $run_link ); + } + + /** + * Test context parameter. + * + * @ticket 64098 + */ + public function test_context_parameter(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' ); + $request->set_param( 'context', 'view' ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'description', $data ); + + $request->set_param( 'context', 'embed' ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'name', $data ); + $this->assertArrayHasKey( 'label', $data ); + } + + /** + * Test schema retrieval. + * + * @ticket 64098 + */ + public function test_get_schema(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'schema', $data ); + $schema = $data['schema']; + + $this->assertEquals( 'ability', $schema['title'] ); + $this->assertEquals( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'properties', $schema ); + + $properties = $schema['properties']; + + // Assert the count of properties to catch when new keys are added + $this->assertCount( 7, $properties, 'Schema should have exactly 7 properties. If this fails, update this test to include the new property.' ); + + // Check all expected properties exist + $this->assertArrayHasKey( 'name', $properties ); + $this->assertArrayHasKey( 'label', $properties ); + $this->assertArrayHasKey( 'description', $properties ); + $this->assertArrayHasKey( 'input_schema', $properties ); + $this->assertArrayHasKey( 'output_schema', $properties ); + $this->assertArrayHasKey( 'meta', $properties ); + $this->assertArrayHasKey( 'category', $properties ); + } + + /** + * Test ability name with valid special characters. + * + * @ticket 64098 + */ + public function test_ability_name_with_valid_special_characters(): void { + // Register ability with hyphen (valid). + wp_register_ability( + 'test-hyphen/ability', + array( + 'label' => 'Test Hyphen Ability', + 'description' => 'Test ability with hyphen', + 'category' => 'general', + 'execute_callback' => static function ( $input ) { + return array( 'success' => true ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Test valid special characters (hyphen, forward slash) + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test-hyphen/ability' ); + $response = $this->server->dispatch( $request ); + + wp_unregister_ability( 'test-hyphen/ability' ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Data provider for invalid ability names. + * + * @return array + */ + public function data_invalid_ability_names_provider(): array { + return array( + '@ symbol' => array( 'test@ability' ), + 'space' => array( 'test ability' ), + 'dot' => array( 'test.ability' ), + 'hash' => array( 'test#ability' ), + 'URL encoded space' => array( 'test%20ability' ), + 'angle brackets' => array( 'test' ), + 'pipe' => array( 'test|ability' ), + 'backslash' => array( 'test\\ability' ), + ); + } + + /** + * Test ability names with invalid special characters. + * + * @ticket 64098 + * + * @dataProvider data_invalid_ability_names_provider + * + * @param string $name Invalid ability name to test. + */ + public function test_ability_name_with_invalid_special_characters( string $name ): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . $name ); + $response = $this->server->dispatch( $request ); + // Should return 404 as the regex pattern won't match + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * Test extremely long ability names. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_registered + */ + public function test_extremely_long_ability_names(): void { + // Create a very long but valid ability name + $long_name = 'test/' . str_repeat( 'a', 1000 ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . $long_name ); + $response = $this->server->dispatch( $request ); + + // Should return 404 as ability doesn't exist + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * Data provider for invalid pagination parameters. + * + * @return array}> + */ + public function data_invalid_pagination_params_provider(): array { + return array( + 'Zero page' => array( array( 'page' => 0 ) ), + 'Negative page' => array( array( 'page' => -1 ) ), + 'Non-numeric page' => array( array( 'page' => 'abc' ) ), + 'Zero per page' => array( array( 'per_page' => 0 ) ), + 'Negative per page' => array( array( 'per_page' => -10 ) ), + 'Exceeds maximum' => array( array( 'per_page' => 1000 ) ), + 'Non-numeric per page' => array( array( 'per_page' => 'all' ) ), + ); + } + + /** + * Test pagination parameters with invalid values. + * + * @ticket 64098 + * + * @dataProvider data_invalid_pagination_params_provider + * + * @param array $params Invalid pagination parameters. + */ + public function test_invalid_pagination_parameters( array $params ): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_query_params( $params ); + + $response = $this->server->dispatch( $request ); + + // Should either use defaults or return error + $this->assertContains( $response->get_status(), array( 200, 400 ) ); + + if ( $response->get_status() !== 200 ) { + return; + } + + // Check that reasonable defaults were used + $data = $response->get_data(); + $this->assertIsArray( $data ); + } + + /** + * Test filtering abilities by category. + * + * @ticket 64098 + */ + public function test_filter_by_category(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'category', 'math' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + + // Should only have math category abilities + foreach ( $data as $ability ) { + $this->assertEquals( 'math', $ability['category'], 'All abilities should be in math category' ); + } + + // Should at least contain the calculator + $ability_names = wp_list_pluck( $data, 'name' ); + $this->assertContains( 'test/calculator', $ability_names ); + $this->assertNotContains( 'test/system-info', $ability_names, 'System info should not be in math category' ); + } + + /** + * Test filtering by non-existent category returns empty results. + * + * @ticket 64098 + */ + public function test_filter_by_nonexistent_category(): void { + // Ensure category doesn't exist - test should fail if it does. + $this->assertFalse( + wp_has_ability_category( 'nonexistent' ), + 'The nonexistent category should not be registered - test isolation may be broken' + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'category', 'nonexistent' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertEmpty( $data, 'Should return empty array for non-existent category' ); + } +} diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php new file mode 100644 index 0000000000000..f610bf6a026e3 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -0,0 +1,1181 @@ +user->create( + array( + 'role' => 'editor', + ) + ); + + self::$no_permission_user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + ) + ); + + // Fire the init hook to allow test ability categories registration. + do_action( 'wp_abilities_api_categories_init' ); + self::register_test_categories(); + } + + /** + * Tear down after class. + */ + public static function tear_down_after_class(): void { + // Clean up registered test ability categories. + foreach ( array( 'math', 'system', 'general' ) as $slug ) { + wp_unregister_ability_category( $slug ); + } + + parent::tear_down_after_class(); + } + + /** + * Set up before each test. + */ + public function set_up(): void { + parent::set_up(); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + + do_action( 'rest_api_init' ); + + // Initialize Abilities API. + do_action( 'wp_abilities_api_init' ); + $this->register_test_abilities(); + + // Set default user for tests + wp_set_current_user( self::$user_id ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + // Clean up test abilities. + foreach ( wp_get_abilities() as $ability ) { + if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) { + continue; + } + + wp_unregister_ability( $ability->get_name() ); + } + + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Register test categories for testing. + */ + public static function register_test_categories(): void { + wp_register_ability_category( + 'math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations and calculations.', + ) + ); + + wp_register_ability_category( + 'system', + array( + 'label' => 'System', + 'description' => 'System information and operations.', + ) + ); + + wp_register_ability_category( + 'general', + array( + 'label' => 'General', + 'description' => 'General purpose abilities.', + ) + ); + } + + /** + * Register test abilities for testing. + */ + private function register_test_abilities(): void { + // Regular ability (POST only). + wp_register_ability( + 'test/calculator', + array( + 'label' => 'Calculator', + 'description' => 'Performs calculations', + 'category' => 'math', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'a' => array( + 'type' => 'number', + 'description' => 'First number', + ), + 'b' => array( + 'type' => 'number', + 'description' => 'Second number', + ), + ), + 'required' => array( 'a', 'b' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'number', + ), + 'execute_callback' => static function ( array $input ) { + return $input['a'] + $input['b']; + }, + 'permission_callback' => static function () { + return current_user_can( 'edit_posts' ); + }, + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Read-only ability (GET method). + wp_register_ability( + 'test/user-info', + array( + 'label' => 'User Info', + 'description' => 'Gets user information', + 'category' => 'system', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'user_id' => array( + 'type' => 'integer', + 'default' => 0, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'login' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => static function ( array $input ) { + $user_id = $input['user_id'] ?? get_current_user_id(); + $user = get_user_by( 'id', $user_id ); + if ( ! $user ) { + return new WP_Error( 'user_not_found', 'User not found' ); + } + return array( + 'id' => $user->ID, + 'login' => $user->user_login, + ); + }, + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + // Destructive ability (DELETE method). + wp_register_ability( + 'test/delete-user', + array( + 'label' => 'Delete User', + 'description' => 'Deletes a user', + 'category' => 'system', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'user_id' => array( + 'type' => 'integer', + 'default' => 0, + ), + ), + ), + 'output_schema' => array( + 'type' => 'string', + 'required' => true, + ), + 'execute_callback' => static function ( array $input ) { + $user_id = $input['user_id'] ?? get_current_user_id(); + $user = get_user_by( 'id', $user_id ); + if ( ! $user ) { + return new WP_Error( 'user_not_found', 'User not found' ); + } + return 'User successfully deleted!'; + }, + 'permission_callback' => static function () { + return is_user_logged_in(); + }, + 'meta' => array( + 'annotations' => array( + 'destructive' => true, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + // Ability with contextual permissions + wp_register_ability( + 'test/restricted', + array( + 'label' => 'Restricted Action', + 'description' => 'Requires specific input for permission', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'secret' => array( 'type' => 'string' ), + 'data' => array( 'type' => 'string' ), + ), + 'required' => array( 'secret', 'data' ), + ), + 'output_schema' => array( + 'type' => 'string', + ), + 'execute_callback' => static function ( array $input ) { + return 'Success: ' . $input['data']; + }, + 'permission_callback' => static function ( array $input ) { + // Only allow if secret matches + return isset( $input['secret'] ) && 'valid_secret' === $input['secret']; + }, + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Ability that does not show in REST. + wp_register_ability( + 'test/not-show-in-rest', + array( + 'label' => 'Hidden from REST', + 'description' => 'It does not show in REST.', + 'category' => 'general', + 'execute_callback' => static function (): int { + return 0; + }, + 'permission_callback' => '__return_true', + ) + ); + + // Ability that returns null + wp_register_ability( + 'test/null-return', + array( + 'label' => 'Null Return', + 'description' => 'Returns null', + 'category' => 'general', + 'execute_callback' => static function () { + return null; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Ability that returns WP_Error + wp_register_ability( + 'test/error-return', + array( + 'label' => 'Error Return', + 'description' => 'Returns error', + 'category' => 'general', + 'execute_callback' => static function () { + return new WP_Error( 'test_error', 'This is a test error' ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Ability with invalid output + wp_register_ability( + 'test/invalid-output', + array( + 'label' => 'Invalid Output', + 'description' => 'Returns invalid output', + 'category' => 'general', + 'output_schema' => array( + 'type' => 'number', + ), + 'execute_callback' => static function () { + return 'not a number'; // Invalid - schema expects number + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Read-only ability for query params testing. + wp_register_ability( + 'test/query-params', + array( + 'label' => 'Query Params Test', + 'description' => 'Tests query parameter handling', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'param1' => array( 'type' => 'string' ), + 'param2' => array( 'type' => 'integer' ), + ), + ), + 'execute_callback' => static function ( $input ) { + return $input; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Test executing a regular ability with POST. + * + * @ticket 64098 + */ + public function test_execute_regular_ability_post(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'a' => 5, + 'b' => 3, + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 8, $response->get_data() ); + } + + /** + * Test executing a read-only ability with GET. + * + * @ticket 64098 + */ + public function test_execute_readonly_ability_get(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/user-info/run' ); + $request->set_query_params( + array( + 'input' => array( + 'user_id' => self::$user_id, + ), + ) + ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( self::$user_id, $data['id'] ); + } + + /** + * Test executing a destructive ability with GET. + * + * @ticket 64098 + */ + public function test_execute_destructive_ability_delete(): void { + $request = new WP_REST_Request( 'DELETE', '/wp-abilities/v1/abilities/test/delete-user/run' ); + $request->set_query_params( + array( + 'input' => array( + 'user_id' => self::$user_id, + ), + ) + ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'User successfully deleted!', $response->get_data() ); + } + + /** + * Test HTTP method validation for regular abilities. + * + * @ticket 64098 + */ + public function test_regular_ability_requires_post(): void { + wp_register_ability( + 'test/open-tool', + array( + 'label' => 'Open Tool', + 'description' => 'Tool with no permission requirements', + 'category' => 'general', + 'execute_callback' => static function () { + return 'success'; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/open-tool/run' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 405, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); + $this->assertSame( 'Abilities that perform updates require POST method.', $data['message'] ); + } + + /** + * Test HTTP method validation for read-only abilities. + * + * @ticket 64098 + */ + public function test_readonly_ability_requires_get(): void { + // Try POST on a read-only ability (should fail). + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/user-info/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 405, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); + $this->assertSame( 'Read-only abilities require GET method.', $data['message'] ); + } + + /** + * Test HTTP method validation for destructive abilities. + * + * @ticket 64098 + */ + public function test_destructive_ability_requires_delete(): void { + // Try POST on a destructive ability (should fail). + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/delete-user/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 405, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); + $this->assertSame( 'Abilities that perform destructive actions require DELETE method.', $data['message'] ); + } + + /** + * Test output validation against schema. + * Note: When output validation fails in WP_Ability::execute(), it returns null, + * which causes the REST controller to return 'ability_invalid_output'. + * + * @ticket 64098 + */ + public function test_output_validation(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/invalid-output/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 500, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'ability_invalid_output', $data['code'] ); + $this->assertSame( + 'Ability "test/invalid-output" has invalid output. Reason: output is not of type number.', + $data['message'] + ); + } + + /** + * Test permission check for execution. + * + * @ticket 64098 + */ + public function test_execution_permission_denied(): void { + wp_set_current_user( self::$no_permission_user_id ); + + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'a' => 5, + 'b' => 3, + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'rest_ability_cannot_execute', $data['code'] ); + $this->assertSame( 'Sorry, you are not allowed to execute this ability.', $data['message'] ); + } + + /** + * Test contextual permission check. + * + * @ticket 64098 + */ + public function test_contextual_permission_check(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/restricted/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'secret' => 'wrong_secret', + 'data' => 'test data', + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 403, $response->get_status() ); + + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'secret' => 'valid_secret', + 'data' => 'test data', + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'Success: test data', $response->get_data() ); + } + + /** + * Test handling an ability that does not show in REST. + * + * @ticket 64098 + */ + public function test_do_not_show_in_rest(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/not-show-in-rest/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_not_found', $data['code'] ); + $this->assertEquals( 'Ability not found.', $data['message'] ); + } + + /** + * Test handling of null is a valid return value. + * + * @ticket 64098 + */ + public function test_null_return_handling(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/null-return/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertNull( $data ); + } + + /** + * Test handling of WP_Error return from ability. + * + * @ticket 64098 + */ + public function test_wp_error_return_handling(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/error-return/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 500, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'test_error', $data['code'] ); + $this->assertEquals( 'This is a test error', $data['message'] ); + } + + /** + * Test non-existent ability returns 404. + * + * @ticket 64098 + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_registered + */ + public function test_execute_non_existent_ability(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/non/existent/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_not_found', $data['code'] ); + } + + /** + * Test schema retrieval for run endpoint. + * + * @ticket 64098 + */ + public function test_run_endpoint_schema(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities/test/calculator/run' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'schema', $data ); + $schema = $data['schema']; + + $this->assertEquals( 'ability-execution', $schema['title'] ); + $this->assertEquals( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'result', $schema['properties'] ); + } + + /** + * Test that invalid JSON in POST body is handled correctly. + * + * @ticket 64098 + */ + public function test_invalid_json_in_post_body(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + // Set raw body with invalid JSON + $request->set_body( '{"input": {invalid json}' ); + + $response = $this->server->dispatch( $request ); + + // When JSON is invalid, WordPress returns 400 Bad Request + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Test GET request with complex nested input array. + * + * @ticket 64098 + */ + public function test_get_request_with_nested_input_array(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/query-params/run' ); + $request->set_query_params( + array( + 'input' => array( + 'level1' => array( + 'level2' => array( + 'value' => 'nested', + ), + ), + 'array' => array( 1, 2, 3 ), + ), + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'nested', $data['level1']['level2']['value'] ); + $this->assertEquals( array( 1, 2, 3 ), $data['array'] ); + } + + /** + * Test GET request with non-array input parameter. + * + * @ticket 64098 + */ + public function test_get_request_with_non_array_input(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/query-params/run' ); + $request->set_query_params( + array( + 'input' => 'not-an-array', // String instead of array + ) + ); + + $response = $this->server->dispatch( $request ); + // When input is not an array, WordPress returns 400 Bad Request + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Test POST request with non-array input in JSON body. + * + * @ticket 64098 + */ + public function test_post_request_with_non_array_input(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => 'string-value', // String instead of array + ) + ) + ); + + $response = $this->server->dispatch( $request ); + // When input is not an array, WordPress returns 400 Bad Request + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Test ability with invalid output that fails validation. + * + * @ticket 64098 + */ + public function test_output_validation_failure_returns_error(): void { + // Register ability with strict output schema. + wp_register_ability( + 'test/strict-output', + array( + 'label' => 'Strict Output', + 'description' => 'Ability with strict output schema', + 'category' => 'general', + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'status' => array( + 'type' => 'string', + 'enum' => array( 'success', 'failure' ), + ), + ), + 'required' => array( 'status' ), + ), + 'execute_callback' => static function () { + // Return invalid output that doesn't match schema + return array( 'wrong_field' => 'value' ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/strict-output/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + + $response = $this->server->dispatch( $request ); + + // Should return error when output validation fails. + $this->assertSame( 500, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'ability_invalid_output', $data['code'] ); + $this->assertSame( + 'Ability "test/strict-output" has invalid output. Reason: status is a required property of output.', + $data['message'] + ); + } + + /** + * Test ability with invalid input that fails validation. + * + * @ticket 64098 + */ + public function test_input_validation_failure_returns_error(): void { + // Register ability with strict input schema. + wp_register_ability( + 'test/strict-input', + array( + 'label' => 'Strict Input', + 'description' => 'Ability with strict input schema', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'required_field' => array( + 'type' => 'string', + ), + ), + 'required' => array( 'required_field' ), + ), + 'execute_callback' => static function () { + return array( 'status' => 'success' ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/strict-input/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + // Missing required field + $request->set_body( wp_json_encode( array( 'input' => array( 'other_field' => 'value' ) ) ) ); + + $response = $this->server->dispatch( $request ); + + // Should return error when input validation fails. + $this->assertSame( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'ability_invalid_input', $data['code'] ); + $this->assertSame( + 'Ability "test/strict-input" has invalid input. Reason: required_field is a required property of input.', + $data['message'] + ); + } + + /** + * Test ability without annotations defaults to POST method. + * + * @ticket 64098 + */ + public function test_ability_without_annotations_defaults_to_post_method(): void { + // Register ability without annotations. + wp_register_ability( + 'test/no-annotations', + array( + 'label' => 'No Annotations', + 'description' => 'Ability without annotations.', + 'category' => 'general', + 'execute_callback' => static function () { + return array( 'executed' => true ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Should require POST (default behavior). + $get_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/no-annotations/run' ); + $get_response = $this->server->dispatch( $get_request ); + $this->assertEquals( 405, $get_response->get_status() ); + + // Should work with POST. + $post_request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/no-annotations/run' ); + $post_request->set_header( 'Content-Type', 'application/json' ); + + $post_response = $this->server->dispatch( $post_request ); + $this->assertEquals( 200, $post_response->get_status() ); + } + + /** + * Test edge case with empty input for both GET and POST methods. + * + * @ticket 64098 + */ + public function test_empty_input_handling(): void { + // Registers abilities for empty input testing. + wp_register_ability( + 'test/read-only-empty', + array( + 'label' => 'Read-only Empty', + 'description' => 'Read-only with empty input.', + 'category' => 'general', + 'execute_callback' => static function () { + return array( 'input_was_empty' => 0 === func_num_args() ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + wp_register_ability( + 'test/regular-empty', + array( + 'label' => 'Regular Empty', + 'description' => 'Regular with empty input.', + 'category' => 'general', + 'execute_callback' => static function () { + return array( 'input_was_empty' => 0 === func_num_args() ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + // Tests GET with no input parameter. + $get_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/read-only-empty/run' ); + $get_response = $this->server->dispatch( $get_request ); + $this->assertEquals( 200, $get_response->get_status() ); + $this->assertTrue( $get_response->get_data()['input_was_empty'] ); + + // Tests POST with no body. + $post_request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/regular-empty/run' ); + $post_request->set_header( 'Content-Type', 'application/json' ); + $post_request->set_body( '{}' ); // Empty JSON object + + $post_response = $this->server->dispatch( $post_request ); + $this->assertEquals( 200, $post_response->get_status() ); + $this->assertTrue( $post_response->get_data()['input_was_empty'] ); + } + + /** + * Data provider for malformed JSON tests. + * + * @return array + */ + public function data_malformed_json_provider(): array { + return array( + 'Missing value' => array( '{"input": }' ), + 'Trailing comma in array' => array( '{"input": [1, 2, }' ), + 'Missing quotes on key' => array( '{input: {}}' ), + 'JavaScript undefined' => array( '{"input": undefined}' ), + 'JavaScript NaN' => array( '{"input": NaN}' ), + 'Missing quotes nested keys' => array( '{"input": {a: 1, b: 2}}' ), + 'Single quotes' => array( '\'{"input": {}}\'' ), + 'Unclosed object' => array( '{"input": {"key": "value"' ), + ); + } + + /** + * Test malformed JSON in POST body. + * + * @ticket 64098 + * + * @dataProvider data_malformed_json_provider + * + * @param string $json Malformed JSON to test. + */ + public function test_malformed_json_post_body( string $json ): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( $json ); + + $response = $this->server->dispatch( $request ); + + // Malformed JSON should result in 400 Bad Request + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Test input with various PHP types as strings. + * + * @ticket 64098 + */ + public function test_php_type_strings_in_input(): void { + // Register ability that accepts any input + wp_register_ability( + 'test/echo', + array( + 'label' => 'Echo', + 'description' => 'Echoes input', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + ), + 'execute_callback' => static function ( $input ) { + return array( 'echo' => $input ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + $inputs = array( + 'null' => null, + 'true' => true, + 'false' => false, + 'int' => 123, + 'float' => 123.456, + 'string' => 'test', + 'empty' => '', + 'zero' => 0, + 'negative' => -1, + ); + + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/echo/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => $inputs ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( $inputs, $data['echo'] ); + } + + /** + * Test input with mixed encoding. + * + * @ticket 64098 + */ + public function test_mixed_encoding_in_input(): void { + // Register ability that accepts any input + wp_register_ability( + 'test/echo-encoding', + array( + 'label' => 'Echo Encoding', + 'description' => 'Echoes input with encoding', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + ), + 'execute_callback' => static function ( $input ) { + return array( 'echo' => $input ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + $input = array( + 'utf8' => 'Hello δΈ–η•Œ', + 'emoji' => 'πŸŽ‰πŸŽŠπŸŽˆ', + 'html' => '', + 'encoded' => '<test>', + 'newlines' => "line1\nline2\rline3\r\nline4", + 'tabs' => "col1\tcol2\tcol3", + 'quotes' => "It's \"quoted\"", + ); + + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/echo-encoding/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => $input ) ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + + // Input should be preserved exactly + $this->assertEquals( $input['utf8'], $data['echo']['utf8'] ); + $this->assertEquals( $input['emoji'], $data['echo']['emoji'] ); + $this->assertEquals( $input['html'], $data['echo']['html'] ); + } + + /** + * Data provider for invalid HTTP methods. + * + * @return array + */ + public function data_invalid_http_methods_provider(): array { + return array( + 'PATCH' => array( 'PATCH' ), + 'PUT' => array( 'PUT' ), + 'DELETE' => array( 'DELETE' ), + 'HEAD' => array( 'HEAD' ), + ); + } + + /** + * Test request with invalid HTTP methods. + * + * @ticket 64098 + * + * @dataProvider data_invalid_http_methods_provider + * + * @param string $method HTTP method to test. + */ + public function test_invalid_http_methods( string $method ): void { + // Register an ability with no permission requirements for this test + wp_register_ability( + 'test/method-test', + array( + 'label' => 'Method Test', + 'description' => 'Test ability for HTTP method validation', + 'category' => 'general', + 'execute_callback' => static function () { + return array( 'success' => true ); + }, + 'permission_callback' => '__return_true', // No permission requirements + 'meta' => array( + 'show_in_rest' => true, + ), + ) + ); + + $request = new WP_REST_Request( $method, '/wp-abilities/v1/abilities/test/method-test/run' ); + $response = $this->server->dispatch( $request ); + + // Regular abilities should only accept POST, so these should return 405. + $this->assertSame( 405, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); + $this->assertSame( 'Abilities that perform updates require POST method.', $data['message'] ); + } + + /** + * Test OPTIONS method handling. + * + * @ticket 64098 + */ + public function test_options_method_handling(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities/test/calculator/run' ); + $response = $this->server->dispatch( $request ); + // OPTIONS requests return 200 with allowed methods + $this->assertEquals( 200, $response->get_status() ); + } +} diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index bc9ea0a2dc424..a47686d6872e3 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -19,7 +19,8 @@ mockedApiResponse.Schema = { "oembed/1.0", "wp/v2", "wp-site-health/v1", - "wp-block-editor/v1" + "wp-block-editor/v1", + "wp-abilities/v1" ], "authentication": { "application-passwords": { @@ -12391,6 +12392,153 @@ mockedApiResponse.Schema = { } } ] + }, + "/wp-abilities/v1": { + "namespace": "wp-abilities/v1", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "namespace": { + "default": "wp-abilities/v1", + "required": false + }, + "context": { + "default": "view", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-abilities/v1" + } + ] + } + }, + "/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run": { + "namespace": "wp-abilities/v1", + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "args": { + "name": { + "description": "Unique identifier for the ability.", + "type": "string", + "pattern": "^[a-zA-Z0-9\\-\\/]+$", + "required": false + }, + "input": { + "description": "Input parameters for the ability execution.", + "type": [ + "integer", + "number", + "boolean", + "string", + "array", + "object", + "null" + ], + "default": null, + "required": false + } + } + } + ] + }, + "/wp-abilities/v1/abilities": { + "namespace": "wp-abilities/v1", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 100, + "required": false + }, + "category": { + "description": "Limit results to abilities in specific ability category.", + "type": "string", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-abilities/v1/abilities" + } + ] + } + }, + "/wp-abilities/v1/abilities/(?P[a-zA-Z0-9\\-\\/]+)": { + "namespace": "wp-abilities/v1", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "name": { + "description": "Unique identifier for the ability.", + "type": "string", + "pattern": "^[a-zA-Z0-9\\-\\/]+$", + "required": false + } + } + } + ] } }, "site_logo": 0,