diff --git a/includes/Abstracts/Abstract_Ability.php b/includes/Abstracts/Abstract_Ability.php index b2f44addc..2546dc263 100644 --- a/includes/Abstracts/Abstract_Ability.php +++ b/includes/Abstracts/Abstract_Ability.php @@ -108,6 +108,121 @@ abstract protected function permission_callback( $input ); */ abstract protected function meta(): array; + /** + * Normalizes the input for the Ability. + * + * Calls the parent method to run the default normalization logic + * and then will run any sanitize_callback functions that are defined + * in the input schema. + * + * @since x.x.x + * + * @param mixed $input The raw input provided for the Ability. Default `null`. + * @return mixed|\WP_Error The normalized input, a default from schema, `null`, or a + * `WP_Error` if a `sanitize_callback` returned one. + */ + public function normalize_input( $input = null ) { + $input = parent::normalize_input( $input ); + $input_schema = $this->get_input_schema(); + + if ( empty( $input_schema ) ) { + return $input; + } + + return $this->sanitize_value_from_input_schema( $input, $input_schema ); + } + + /** + * Validates input data against the input schema. + * + * Short-circuits when normalize_input() returned a WP_Error from a sanitize callback. + * + * @since x.x.x + * + * @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 ) { + if ( is_wp_error( $input ) ) { + return $input; + } + + return parent::validate_input( $input ); + } + + /** + * Applies input_schema sanitize_callback entries recursively. + * + * @since x.x.x + * + * @param mixed $value The input value to sanitize. + * @param array $schema The JSON schema fragment. + * @return mixed|\WP_Error The sanitized value, or a WP_Error if a callback returned one. + */ + protected function sanitize_value_from_input_schema( $value, array $schema ) { + if ( isset( $schema['sanitize_callback'] ) && is_callable( $schema['sanitize_callback'] ) ) { + $sanitized = call_user_func( $schema['sanitize_callback'], $value ); + if ( is_wp_error( $sanitized ) ) { + return $sanitized; + } + return $sanitized; + } + + $schema_type = $schema['type'] ?? null; + $is_object = ( 'object' === $schema_type && ! empty( $schema['properties'] ) && is_array( $schema['properties'] ) ); + if ( ! $is_object && 'object' !== $schema_type && ! empty( $schema['properties'] ) && is_array( $schema['properties'] ) ) { + $is_object = true; + } + + if ( $is_object ) { + if ( ! is_array( $value ) ) { + return $value; + } + + $property_schemas = $schema['properties']; + foreach ( $property_schemas as $key => $prop_schema ) { + if ( ! is_array( $prop_schema ) || ! array_key_exists( $key, $value ) ) { + continue; + } + $sanitized = $this->sanitize_value_from_input_schema( $value[ $key ], $prop_schema ); + if ( is_wp_error( $sanitized ) ) { + return $sanitized; + } + $value[ $key ] = $sanitized; + } + + if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) { + $addl = $schema['additionalProperties']; + foreach ( $value as $key => $v ) { + if ( array_key_exists( $key, $property_schemas ) ) { + continue; + } + $sanitized = $this->sanitize_value_from_input_schema( $v, $addl ); + if ( is_wp_error( $sanitized ) ) { + return $sanitized; + } + $value[ $key ] = $sanitized; + } + } + + return $value; + } + + $is_array = ( 'array' === $schema_type && ! empty( $schema['items'] ) && is_array( $schema['items'] ) ); + if ( $is_array && is_array( $value ) ) { + $items = $schema['items']; + foreach ( $value as $i => $item ) { + $sanitized = $this->sanitize_value_from_input_schema( $item, $items ); + if ( is_wp_error( $sanitized ) ) { + return $sanitized; + } + $value[ $i ] = $sanitized; + } + } + + return $value; + } + /** * Returns the guideline categories this ability uses. * diff --git a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php index 3277c2729..a2e88ea57 100644 --- a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php +++ b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php @@ -102,6 +102,391 @@ protected function meta(): array { } } +/** + * Test ability: sanitize_text_field on a string input property. + * + * @since x.x.x + */ +class Test_Ability_Sanitize_Text extends Abstract_Ability { + /** + * Last input received by execute_callback. + * + * @var mixed + */ + public $last_input; + + /** + * {@inheritDoc} + */ + protected function category(): string { + return 'test-category'; + } + + /** + * {@inheritDoc} + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'content' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'Content.', + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'ok' => array( 'type' => 'boolean' ), + ), + ); + } + + /** + * {@inheritDoc} + */ + protected function execute_callback( $input ) { + $this->last_input = $input; + return array( 'ok' => true ); + } + + /** + * {@inheritDoc} + */ + protected function permission_callback( $input ) { + return true; + } + + /** + * {@inheritDoc} + */ + protected function meta(): array { + return array(); + } +} + +/** + * Test ability: absint and instance-method sanitize callback. + * + * @since x.x.x + */ +class Test_Ability_Sanitize_Callback_Styles extends Abstract_Ability { + /** + * Last input received by execute_callback. + * + * @var mixed + */ + public $last_input; + + /** + * {@inheritDoc} + */ + protected function category(): string { + return 'test-category'; + } + + /** + * {@inheritDoc} + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'count' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => 'Count.', + ), + 'prefix' => array( + 'type' => 'string', + 'sanitize_callback' => array( $this, 'add_prefix' ), + 'description' => 'Prefixed.', + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'ok' => array( 'type' => 'boolean' ), + ), + ); + } + + /** + * Test sanitize callback. + * + * @param mixed $value Value. + * @return string + */ + public function add_prefix( $value ): string { + return is_string( $value ) ? 'P:' . $value : ''; + } + + /** + * {@inheritDoc} + */ + protected function execute_callback( $input ) { + $this->last_input = $input; + return array( 'ok' => true ); + } + + /** + * {@inheritDoc} + */ + protected function permission_callback( $input ) { + return true; + } + + /** + * {@inheritDoc} + */ + protected function meta(): array { + return array(); + } +} + +/** + * Test ability: nested object properties and array items with callbacks. + * + * @since x.x.x + */ +class Test_Ability_Sanitize_Nested_And_Items extends Abstract_Ability { + /** + * Last input received by execute_callback. + * + * @var mixed + */ + public $last_input; + + /** + * {@inheritDoc} + */ + protected function category(): string { + return 'test-category'; + } + + /** + * {@inheritDoc} + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'parent' => array( + 'type' => 'object', + 'properties' => array( + 'label' => array( + 'type' => 'string', + 'sanitize_callback' => 'strtoupper', + ), + ), + ), + 'fragments' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'ok' => array( 'type' => 'boolean' ), + ), + ); + } + + /** + * {@inheritDoc} + */ + protected function execute_callback( $input ) { + $this->last_input = $input; + return array( 'ok' => true ); + } + + /** + * {@inheritDoc} + */ + protected function permission_callback( $input ) { + return true; + } + + /** + * {@inheritDoc} + */ + protected function meta(): array { + return array(); + } +} + +/** + * Test ability: top-level object default in input schema with per-field callbacks. + * + * @since x.x.x + */ +class Test_Ability_Sanitize_Top_Level_Default extends Abstract_Ability { + /** + * Last input received by execute_callback. + * + * @var mixed + */ + public $last_input; + + /** + * {@inheritDoc} + */ + protected function category(): string { + return 'test-category'; + } + + /** + * {@inheritDoc} + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'default' => array( 'k' => '7' ), + 'properties' => array( + 'k' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'ok' => array( 'type' => 'boolean' ), + ), + ); + } + + /** + * {@inheritDoc} + */ + protected function execute_callback( $input ) { + $this->last_input = $input; + return array( 'ok' => true ); + } + + /** + * {@inheritDoc} + */ + protected function permission_callback( $input ) { + return true; + } + + /** + * {@inheritDoc} + */ + protected function meta(): array { + return array(); + } +} + +/** + * Test ability: sanitize returns WP_Error. + * + * @since x.x.x + */ +class Test_Ability_Sanitize_Returns_Error extends Abstract_Ability { + /** + * {@inheritDoc} + */ + protected function category(): string { + return 'test-category'; + } + + /** + * {@inheritDoc} + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'x' => array( + 'type' => 'string', + 'sanitize_callback' => array( $this, 'failing_sanitize' ), + ), + ), + ); + } + + /** + * {@inheritDoc} + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'ok' => array( 'type' => 'boolean' ), + ), + ); + } + + /** + * Returns a WP_Error for "bad" input. + * + * @param mixed $value Value. + * @return mixed|\WP_Error + */ + public function failing_sanitize( $value ) { + if ( 'bad' === $value ) { + return new WP_Error( 'test_sanitize', 'Failure from sanitize.' ); + } + return sanitize_text_field( (string) $value ); + } + + /** + * {@inheritDoc} + */ + protected function execute_callback( $input ) { + return array( 'ok' => true ); + } + + /** + * {@inheritDoc} + */ + protected function permission_callback( $input ) { + return true; + } + + /** + * {@inheritDoc} + */ + protected function meta(): array { + return array(); + } +} + /** * Test experiment for Abstract_Ability tests. * @@ -152,14 +537,14 @@ class Abstract_AbilityTest extends WP_UnitTestCase { /** * Test experiment instance. * - * @var Test_Ability_Experiment + * @var \WordPress\AI\Tests\Integration\Includes\Abstracts\Test_Ability_Experiment */ private Test_Ability_Experiment $experiment; /** * Test ability instance. * - * @var Test_Ability + * @var \WordPress\AI\Tests\Integration\Includes\Abstracts\Test_Ability */ private Test_Ability $ability; @@ -200,8 +585,8 @@ public function setUp(): void { ) ); - $reflection = new \ReflectionClass( $this->ability ); - $file_name = $reflection->getFileName(); + $reflection = new \ReflectionClass( $this->ability ); + $file_name = $reflection->getFileName(); $this->feature_dir = dirname( $file_name ); } @@ -212,9 +597,11 @@ public function setUp(): void { */ public function tearDown(): void { foreach ( $this->temp_files as $file ) { - if ( file_exists( $file ) ) { - wp_delete_file( $file ); + if ( ! file_exists( $file ) ) { + continue; } + + wp_delete_file( $file ); } $this->temp_files = array(); @@ -234,9 +621,11 @@ private function create_system_instruction_file( string $filename, string $conte $this->temp_files[] = $path; $result = @file_put_contents( $path, $content ); - if ( false === $result ) { - $this->fail( sprintf( 'Failed to create system instruction file at path: %s', $path ) ); + if ( false !== $result ) { + return; } + + $this->fail( sprintf( 'Failed to create system instruction file at path: %s', $path ) ); } /** @@ -451,7 +840,7 @@ public function test_system_instruction_filter() { ) ); - $filter_callback = function ( $instruction, $name, $data ) { + $filter_callback = static function ( $instruction, $name, $data ) { return $instruction . ' Appended by filter.'; }; @@ -591,4 +980,121 @@ public function is_supported_for_image_generation(): bool { $this->assertSame( $prompt_builder, $result, 'Should return the same builder instance when supported' ); } + + /** + * Tests that execute_callback receives input with sanitize_text_field applied from input_schema. + * + * @since x.x.x + */ + public function test_normalize_input_applies_sanitize_text_field_to_string(): void { + $experiment = new Test_Ability_Experiment(); + $ability = new Test_Ability_Sanitize_Text( + 'ai/test-sanitize-text', + array( + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), + ) + ); + + $ability->execute( array( 'content' => 'Hello' ) ); + + $this->assertIsArray( $ability->last_input ); + $this->assertArrayHasKey( 'content', $ability->last_input, 'Stripped tags' ); + $this->assertSame( 'Hello', $ability->last_input['content'] ); + } + + /** + * Tests that absint and object-method sanitize_callback run before execute. + * + * @since x.x.x + */ + public function test_normalize_input_applies_absint_and_method_callback(): void { + $experiment = new Test_Ability_Experiment(); + $ability = new Test_Ability_Sanitize_Callback_Styles( + 'ai/test-sanitize-callback-styles', + array( + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), + ) + ); + + $ability->execute( + array( + 'count' => ' 42 ', + 'prefix' => 'x', + ) + ); + + $this->assertSame( 42, $ability->last_input['count'] ); + $this->assertSame( 'P:x', $ability->last_input['prefix'] ); + } + + /** + * Tests nested object properties and array item sanitize_callback application. + * + * @since x.x.x + */ + public function test_normalize_input_sanitizes_nested_and_array_items(): void { + $experiment = new Test_Ability_Experiment(); + $ability = new Test_Ability_Sanitize_Nested_And_Items( + 'ai/test-sanitize-nested', + array( + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), + ) + ); + + $ability->execute( + array( + 'parent' => array( 'label' => 'ab' ), + 'fragments' => array( 'one', ' two ' ), + ) + ); + + $this->assertSame( 'AB', $ability->last_input['parent']['label'] ); + $this->assertSame( array( 'one', 'two' ), $ability->last_input['fragments'] ); + } + + /** + * Tests that a top-level default from the input schema is passed through sanitize_callback. + * + * @since x.x.x + */ + public function test_normalize_input_sanitizes_top_level_object_default(): void { + $experiment = new Test_Ability_Experiment(); + $ability = new Test_Ability_Sanitize_Top_Level_Default( + 'ai/test-sanitize-default', + array( + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), + ) + ); + + $ability->execute( null ); + + $this->assertIsArray( $ability->last_input ); + $this->assertArrayHasKey( 'k', $ability->last_input ); + $this->assertSame( 7, $ability->last_input['k'] ); + } + + /** + * Tests that a WP_Error from a sanitize_callback is returned from execute. + * + * @since x.x.x + */ + public function test_normalize_input_propagates_wp_error_from_sanitize_callback(): void { + $experiment = new Test_Ability_Experiment(); + $ability = new Test_Ability_Sanitize_Returns_Error( + 'ai/test-sanitize-wp-error', + array( + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), + ) + ); + + $result = $ability->execute( array( 'x' => 'bad' ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'test_sanitize', $result->get_error_code() ); + } }