diff --git a/abilities-api.php b/abilities-api.php index 2bb6fa55..39dafd64 100644 --- a/abilities-api.php +++ b/abilities-api.php @@ -45,3 +45,8 @@ * Then the public access functions that users can use to interact with the abilities. */ require_once WP_ABILITIES_API_DIR . 'src/abilities-api.php'; + +/** + * Initialize REST API controllers. + */ +require_once WP_ABILITIES_API_DIR . 'src/rest/class-wp-rest-abilities-init.php'; diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 1af8dc9a..2f74b5f8 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -193,6 +193,22 @@ + + + + src/rest/class-wp-rest-abilities-*-controller.php + + + src/rest/class-wp-rest-abilities-*-controller.php + + + src/rest/class-wp-rest-abilities-*-controller.php + @@ -254,6 +270,7 @@ + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index bf768e8d..9f8e19b7 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -29,3 +29,14 @@ parameters: analyseAndScan: - node_modules (?) + # Ignore specific errors + ignoreErrors: + # WP_REST_Request is not actually a generic class in WordPress core. + # PHPStan's WordPress stubs appear to define it as generic for type checking, + # but WordPress itself doesn't use generics. This seems to be an incompatibility + # between static analysis tools and WordPress's actual implementation. + - + message: '#has parameter \$request with generic class WP_REST_Request but does not specify its types#' + paths: + - src/rest/*.php + diff --git a/src/rest/class-wp-rest-abilities-init.php b/src/rest/class-wp-rest-abilities-init.php new file mode 100644 index 00000000..ed71c2a9 --- /dev/null +++ b/src/rest/class-wp-rest-abilities-init.php @@ -0,0 +1,36 @@ +register_routes(); + + $list_controller = new WP_REST_Abilities_List_Controller(); + $list_controller->register_routes(); + } +} + +add_action( 'rest_api_init', array( 'WP_REST_Abilities_Init', 'register_routes' ) ); diff --git a/src/rest/class-wp-rest-abilities-list-controller.php b/src/rest/class-wp-rest-abilities-list-controller.php new file mode 100644 index 00000000..aeeb527c --- /dev/null +++ b/src/rest/class-wp-rest-abilities-list-controller.php @@ -0,0 +1,307 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_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.', 'abilities-api' ), + 'type' => 'string', + 'pattern' => '^[a-zA-Z0-9\-\/]+$', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_permissions_check' ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Retrieves all abilities. + * + * @since 0.1.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response Response object on success. + */ + public function get_items( $request ) { + // TODO: Add HEAD method support for performance optimization. + // Should return early with empty body but include X-WP-Total and X-WP-TotalPages headers. + // See: https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php#L316-L318 + + $abilities = wp_get_abilities(); + + // Handle pagination with explicit defaults. + $page = isset( $request['page'] ) ? $request['page'] : 1; + $per_page = isset( $request['per_page'] ) ? $request['per_page'] : self::DEFAULT_PER_PAGE; + $offset = ( $page - 1 ) * $per_page; + + $total_abilities = count( $abilities ); + $max_pages = ceil( $total_abilities / $per_page ); + + $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 ); + + $request_params = $request->get_query_params(); + $base = add_query_arg( urlencode_deep( $request_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->add_link( 'prev', $prev_link ); + } + + if ( $page < $max_pages ) { + $next_page = $page + 1; + $next_link = add_query_arg( 'page', $next_page, $base ); + $response->add_link( 'next', $next_link ); + } + + return $response; + } + + /** + * Retrieves a specific ability. + * + * @since 0.1.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 ) { + return new \WP_Error( + 'rest_ability_not_found', + __( 'Ability not found.', 'abilities-api' ), + 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 abilities. + * + * @since 0.1.0 + * + * @param \WP_REST_Request $request Full details about the request. + * @return boolean True if the request has read access. + */ + public function get_permissions_check( $request ) { + return current_user_can( 'read' ); + } + + /** + * Prepares an ability for response. + * + * @since 0.1.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(), + 'input_schema' => $ability->get_input_schema(), + 'output_schema' => $ability->get_output_schema(), + 'meta' => $ability->get_meta(), + ); + + $context = ! empty( $request['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['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 0.1.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.', 'abilities-api' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'label' => array( + 'description' => __( 'Display label for the ability.', 'abilities-api' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'description' => array( + 'description' => __( 'Description of the ability.', 'abilities-api' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'input_schema' => array( + 'description' => __( 'JSON Schema for the ability input.', 'abilities-api' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'output_schema' => array( + 'description' => __( 'JSON Schema for the ability output.', 'abilities-api' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'meta' => array( + 'description' => __( 'Meta information about the ability.', 'abilities-api' ), + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ), + 'required' => array( 'name', 'label', 'description' ), + ); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Retrieves the query params for collections. + * + * @since 0.1.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.', 'abilities-api' ), + 'type' => 'integer', + 'default' => 1, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + 'minimum' => 1, + ), + 'per_page' => array( + 'description' => __( 'Maximum number of items to be returned in result set.', 'abilities-api' ), + 'type' => 'integer', + 'default' => self::DEFAULT_PER_PAGE, + 'minimum' => 1, + 'maximum' => 100, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + ), + ); + } +} diff --git a/src/rest/class-wp-rest-abilities-run-controller.php b/src/rest/class-wp-rest-abilities-run-controller.php new file mode 100644 index 00000000..02c67345 --- /dev/null +++ b/src/rest/class-wp-rest-abilities-run-controller.php @@ -0,0 +1,331 @@ +namespace, + '/' . $this->rest_base . '/(?P[a-zA-Z0-9\-\/]+?)/run', + array( + 'args' => array( + 'name' => array( + 'description' => __( 'Unique identifier for the ability.', 'abilities-api' ), + '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 types (resource vs tool). 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, 'run_ability_with_method_check' ), + 'permission_callback' => array( $this, 'run_ability_permissions_check' ), + 'args' => $this->get_run_args(), + ), + 'schema' => array( $this, 'get_run_schema' ), + ) + ); + } + + /** + * Executes an ability with HTTP method validation. + * + * @since 0.1.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 run_ability_with_method_check( $request ) { + $ability = wp_get_ability( $request['name'] ); + + if ( ! $ability ) { + return new \WP_Error( + 'rest_ability_not_found', + __( 'Ability not found.', 'abilities-api' ), + array( 'status' => 404 ) + ); + } + + // Check if the HTTP method matches the ability type. + $meta = $ability->get_meta(); + $type = isset( $meta['type'] ) ? $meta['type'] : 'tool'; + $method = $request->get_method(); + + if ( 'resource' === $type && 'GET' !== $method ) { + return new \WP_Error( + 'rest_invalid_method', + __( 'Resource abilities require GET method.', 'abilities-api' ), + array( 'status' => 405 ) + ); + } + + if ( 'tool' === $type && 'POST' !== $method ) { + return new \WP_Error( + 'rest_invalid_method', + __( 'Tool abilities require POST method.', 'abilities-api' ), + array( 'status' => 405 ) + ); + } + + return $this->run_ability( $request ); + } + + /** + * Executes an ability. + * + * @since 0.1.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 run_ability( $request ) { + $ability = wp_get_ability( $request['name'] ); + + if ( ! $ability ) { + return new \WP_Error( + 'rest_ability_not_found', + __( 'Ability not found.', 'abilities-api' ), + array( 'status' => 404 ) + ); + } + + $input = $this->get_input_from_request( $request ); + + // REST API needs detailed error messages with HTTP status codes. + // While WP_Ability::execute() validates internally, it only returns false + // and logs with _doing_it_wrong, which doesn't provide capturable error messages. + // TODO: Consider updating WP_Ability to return WP_Error for better error handling. + $input_validation = $this->validate_input( $ability, $input ); + if ( is_wp_error( $input_validation ) ) { + return $input_validation; + } + + $result = $ability->execute( $input ); + + if ( is_wp_error( $result ) ) { + return new \WP_Error( + 'rest_ability_execution_failed', + $result->get_error_message(), + array( 'status' => 500 ) + ); + } + + if ( is_null( $result ) ) { + return new \WP_Error( + 'rest_ability_execution_failed', + __( 'Ability execution failed. Please check permissions and input parameters.', 'abilities-api' ), + array( 'status' => 500 ) + ); + } + + $output_validation = $this->validate_output( $ability, $result ); + if ( is_wp_error( $output_validation ) ) { + return $output_validation; + } + + return rest_ensure_response( $result ); + } + + /** + * Checks if a given request has permission to execute a specific ability. + * + * @since 0.1.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 run_ability_permissions_check( $request ) { + $ability = wp_get_ability( $request['name'] ); + + if ( ! $ability ) { + return new \WP_Error( + 'rest_ability_not_found', + __( 'Ability not found.', 'abilities-api' ), + array( 'status' => 404 ) + ); + } + + $input = $this->get_input_from_request( $request ); + + if ( ! $ability->has_permission( $input ) ) { + return new \WP_Error( + 'rest_cannot_execute', + __( 'Sorry, you are not allowed to execute this ability.', 'abilities-api' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Validates input data against the ability's input schema. + * + * @since 0.1.0 + * + * @param \WP_Ability $ability The ability object. + * @param array $input The input data to validate. + * @return true|\WP_Error True if validation passes, WP_Error object on failure. + */ + private function validate_input( $ability, $input ) { + $input_schema = $ability->get_input_schema(); + + if ( empty( $input_schema ) ) { + return true; + } + + $validation_result = rest_validate_value_from_schema( $input, $input_schema ); + if ( is_wp_error( $validation_result ) ) { + return new \WP_Error( + 'rest_invalid_param', + sprintf( + /* translators: %s: error message */ + __( 'Invalid input parameters: %s', 'abilities-api' ), + $validation_result->get_error_message() + ), + array( 'status' => 400 ) + ); + } + + return true; + } + + /** + * Validates output data against the ability's output schema. + * + * @since 0.1.0 + * + * @param \WP_Ability $ability The ability object. + * @param mixed $output The output data to validate. + * @return true|\WP_Error True if validation passes, WP_Error object on failure. + */ + private function validate_output( $ability, $output ) { + $output_schema = $ability->get_output_schema(); + + if ( empty( $output_schema ) ) { + return true; + } + + $validation_result = rest_validate_value_from_schema( $output, $output_schema ); + if ( is_wp_error( $validation_result ) ) { + return new \WP_Error( + 'rest_invalid_response', + sprintf( + /* translators: %s: error message */ + __( 'Invalid response from ability: %s', 'abilities-api' ), + $validation_result->get_error_message() + ), + array( 'status' => 500 ) + ); + } + + return true; + } + + /** + * Extracts input parameters from the request. + * + * @since 0.1.0 + * + * @param \WP_REST_Request $request The request object. + * @return array The input parameters. + */ + private function get_input_from_request( $request ) { + if ( 'GET' === $request->get_method() ) { + // For GET requests, look for 'input' query parameter. + $query_params = $request->get_query_params(); + return isset( $query_params['input'] ) && is_array( $query_params['input'] ) + ? $query_params['input'] + : array(); + } + + // For POST requests, look for 'input' in JSON body. + $json_params = $request->get_json_params(); + return isset( $json_params['input'] ) && is_array( $json_params['input'] ) + ? $json_params['input'] + : array(); + } + + /** + * Retrieves the arguments for ability execution endpoint. + * + * @since 0.1.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.', 'abilities-api' ), + 'type' => 'object', + 'default' => array(), + ), + ); + } + + /** + * Retrieves the schema for ability execution endpoint. + * + * @since 0.1.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.', 'abilities-api' ), + 'type' => 'mixed', + 'context' => array( 'view' ), + 'readonly' => true, + ), + ), + ); + } +} diff --git a/tests/unit/AbilitiesAPITest.php b/tests/unit/AbilitiesAPITest.php index c70d1dde..a4c4c82d 100644 --- a/tests/unit/AbilitiesAPITest.php +++ b/tests/unit/AbilitiesAPITest.php @@ -87,8 +87,21 @@ public function test_register_ability_invalid_name(): void { * @expectedIncorrectUsage wp_register_ability */ public function test_register_ability_no_abilities_api_init_hook(): void { + global $wp_actions; + + // Store the original action count + $original_count = isset( $wp_actions['abilities_api_init'] ) ? $wp_actions['abilities_api_init'] : 0; + + // Reset the action count to simulate it not being fired + unset( $wp_actions['abilities_api_init'] ); + $result = wp_register_ability( self::$test_ability_name, self::$test_ability_properties ); + // Restore the original action count + if ( $original_count > 0 ) { + $wp_actions['abilities_api_init'] = $original_count; + } + $this->assertNull( $result ); } diff --git a/tests/unit/REST/WPRESTAbilitiesInitTest.php b/tests/unit/REST/WPRESTAbilitiesInitTest.php new file mode 100644 index 00000000..84c92fa5 --- /dev/null +++ b/tests/unit/REST/WPRESTAbilitiesInitTest.php @@ -0,0 +1,168 @@ +server = $wp_rest_server = new WP_REST_Server(); + } + + /** + * Clean up after each test. + */ + public function tear_down(): void { + parent::tear_down(); + + global $wp_rest_server; + $wp_rest_server = null; + } + + /** + * Test that routes are registered when rest_api_init fires. + */ + public function test_routes_registered_on_rest_api_init(): void { + // Routes should not exist before init + $routes = $this->server->get_routes(); + $this->assertArrayNotHasKey( '/wp/v2/abilities', $routes ); + $this->assertArrayNotHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+)', $routes ); + $this->assertArrayNotHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', $routes ); + + // Trigger rest_api_init + do_action( 'rest_api_init' ); + + // Routes should now be registered + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/wp/v2/abilities', $routes ); + $this->assertArrayHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+)', $routes ); + $this->assertArrayHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', $routes ); + } + + /** + * Test that the correct controller classes are instantiated. + */ + public function test_correct_controllers_instantiated(): void { + // Trigger rest_api_init + do_action( 'rest_api_init' ); + + $routes = $this->server->get_routes(); + + // Check list controller + $this->assertArrayHasKey( '/wp/v2/abilities', $routes ); + $list_route = $routes['/wp/v2/abilities'][0]; + $this->assertIsArray( $list_route['callback'] ); + $this->assertInstanceOf( 'WP_REST_Abilities_List_Controller', $list_route['callback'][0] ); + + // Check run controller + $this->assertArrayHasKey( '/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run', $routes ); + $run_route = $routes['/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run'][0]; + $this->assertIsArray( $run_route['callback'] ); + $this->assertInstanceOf( 'WP_REST_Abilities_Run_Controller', $run_route['callback'][0] ); + } + + /** + * Test that the init class loads required files. + */ + public function test_required_files_loaded(): void { + // Classes should be available after requiring the main plugin file + $this->assertTrue( class_exists( 'WP_REST_Abilities_Init' ) ); + $this->assertTrue( class_exists( 'WP_REST_Abilities_List_Controller' ) ); + $this->assertTrue( class_exists( 'WP_REST_Abilities_Run_Controller' ) ); + } + + /** + * Test that routes support expected HTTP methods. + */ + public function test_routes_support_expected_methods(): void { + do_action( 'rest_api_init' ); + + $routes = $this->server->get_routes(); + + // List endpoint should support GET + $list_methods = $routes['/wp/v2/abilities'][0]['methods']; + // Methods can be a string like 'GET' or an array of method constants + if ( is_string( $list_methods ) ) { + $this->assertEquals( WP_REST_Server::READABLE, $list_methods ); + } else { + // Just check it's set, don't check specific values + $this->assertNotEmpty( $list_methods ); + } + + // Single ability endpoint should support GET + $single_methods = $routes['/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+)'][0]['methods']; + // Methods can be a string like 'GET' or an array of method constants + if ( is_string( $single_methods ) ) { + $this->assertEquals( WP_REST_Server::READABLE, $single_methods ); + } else { + // Just check it's set, don't check specific values + $this->assertNotEmpty( $single_methods ); + } + + // Run endpoint should support all methods (for type-based routing) + $run_route = $routes['/wp/v2/abilities/(?P[a-zA-Z0-9\\-\\/]+?)/run'][0]; + // ALLMETHODS can be a string or array + if ( is_string( $run_route['methods'] ) ) { + $this->assertEquals( WP_REST_Server::ALLMETHODS, $run_route['methods'] ); + } else { + // Just verify it has methods + $this->assertNotEmpty( $run_route['methods'] ); + } + } + + /** + * Test namespace and base configuration. + */ + public function test_namespace_and_base_configuration(): void { + do_action( 'rest_api_init' ); + + $namespaces = $this->server->get_namespaces(); + $this->assertContains( 'wp/v2', $namespaces ); + + // Verify abilities endpoints are under wp/v2 namespace + $routes = $this->server->get_routes(); + foreach ( array_keys( $routes ) as $route ) { + if ( strpos( $route, 'abilities' ) !== false && $route !== '/' ) { + $this->assertStringStartsWith( '/wp/v2/abilities', $route ); + } + } + } + + /** + * Test that multiple calls to register_routes don't duplicate routes. + */ + public function test_no_duplicate_routes_on_multiple_init(): void { + // First init + do_action( 'rest_api_init' ); + + $routes_first = $this->server->get_routes(); + $abilities_route_count_first = count( $routes_first['/wp/v2/abilities'] ?? array() ); + + // Second init (simulating multiple calls) + // Note: WordPress doesn't prevent duplicate registration, so we expect 2x routes + WP_REST_Abilities_Init::register_routes(); + + $routes_second = $this->server->get_routes(); + $abilities_route_count_second = count( $routes_second['/wp/v2/abilities'] ?? array() ); + + // WordPress allows duplicate route registration + $this->assertEquals( $abilities_route_count_first * 2, $abilities_route_count_second ); + } +} \ No newline at end of file diff --git a/tests/unit/REST/WPRESTAbilitiesListControllerTest.php b/tests/unit/REST/WPRESTAbilitiesListControllerTest.php new file mode 100644 index 00000000..bb3c27a5 --- /dev/null +++ b/tests/unit/REST/WPRESTAbilitiesListControllerTest.php @@ -0,0 +1,509 @@ +user->create( + array( + 'role' => 'subscriber', + ) + ); + } + + /** + * Set up before each test. + */ + public function set_up(): void { + parent::set_up(); + + // Set up REST server + global $wp_rest_server; + $this->server = $wp_rest_server = new WP_REST_Server(); + do_action( 'rest_api_init' ); + + // Initialize abilities API + do_action( 'abilities_api_init' ); + + // Register test abilities + $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/' ) ) { + wp_unregister_ability( $ability->get_name() ); + } + } + + // Reset REST server + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Register test abilities for testing. + */ + private function register_test_abilities(): void { + // Register a tool ability + wp_register_ability( + 'test/calculator', + array( + 'label' => 'Calculator', + 'description' => 'Performs basic calculations', + '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' => 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 $input['b'] !== 0 ? $input['a'] / $input['b'] : null; + default: + return null; + } + }, + 'permission_callback' => function () { + return current_user_can( 'read' ); + }, + 'meta' => array( + 'type' => 'tool', + 'category' => 'math', + ), + ) + ); + + // Register a resource ability + wp_register_ability( + 'test/system-info', + array( + 'label' => 'System Info', + 'description' => 'Returns system information', + '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' => 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' => function () { + return current_user_can( 'read' ); + }, + 'meta' => array( + 'type' => 'resource', + 'category' => 'system', + ), + ) + ); + + // 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}", + 'execute_callback' => function () use ( $i ) { + return "Result from ability {$i}"; + }, + 'permission_callback' => '__return_true', + ) + ); + } + } + + /** + * Test listing all abilities. + */ + public function test_get_items(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/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 ); + } + + /** + * Test getting a specific ability. + */ + public function test_get_item(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'test/calculator', $data['name'] ); + $this->assertEquals( 'Calculator', $data['label'] ); + $this->assertEquals( 'Performs basic calculations', $data['description'] ); + $this->assertArrayHasKey( 'input_schema', $data ); + $this->assertArrayHasKey( 'output_schema', $data ); + $this->assertArrayHasKey( 'meta', $data ); + $this->assertEquals( 'tool', $data['meta']['type'] ); + } + + /** + * Test getting a non-existent ability returns 404. + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_registered + */ + public function test_get_item_not_found(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/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 permission check for listing abilities. + */ + 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/v2/abilities' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * Test pagination headers. + */ + public function test_pagination_headers(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/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() ); + $this->assertEquals( $total_abilities, (int) $headers['X-WP-Total'] ); + $this->assertEquals( ceil( $total_abilities / 10 ), (int) $headers['X-WP-TotalPages'] ); + } + + /** + * Test pagination links. + */ + public function test_pagination_links(): void { + // Test first page (should have 'next' link but no 'prev') + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities' ); + $request->set_param( 'per_page', 10 ); + $request->set_param( 'page', 1 ); + $response = $this->server->dispatch( $request ); + + $links = $response->get_links(); + $this->assertArrayHasKey( 'next', $links ); + $this->assertArrayNotHasKey( 'prev', $links ); + + // Test middle page (should have both 'next' and 'prev' links) + $request->set_param( 'page', 3 ); + $response = $this->server->dispatch( $request ); + + $links = $response->get_links(); + $this->assertArrayHasKey( 'next', $links ); + $this->assertArrayHasKey( 'prev', $links ); + + // Test last page (should have 'prev' link 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 ); + + $links = $response->get_links(); + $this->assertArrayNotHasKey( 'next', $links ); + $this->assertArrayHasKey( 'prev', $links ); + } + + /** + * Test collection parameters. + */ + public function test_collection_params(): void { + // Test per_page parameter + $request = new WP_REST_Request( 'GET', '/wp/v2/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/v2/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. + */ + public function test_ability_response_links(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/calculator' ); + $response = $this->server->dispatch( $request ); + + $links = $response->get_links(); + $this->assertArrayHasKey( 'self', $links ); + $this->assertArrayHasKey( 'collection', $links ); + $this->assertArrayHasKey( 'run', $links ); + + // Verify link URLs + $self_link = $links['self'][0]['href']; + $this->assertStringContainsString( '/wp/v2/abilities/test/calculator', $self_link ); + + $collection_link = $links['collection'][0]['href']; + $this->assertStringContainsString( '/wp/v2/abilities', $collection_link ); + + $run_link = $links['run'][0]['href']; + $this->assertStringContainsString( '/wp/v2/abilities/test/calculator/run', $run_link ); + } + + /** + * Test context parameter. + */ + public function test_context_parameter(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/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. + */ + public function test_get_schema(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/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']; + $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 ); + } + + /** + * Test ability name with valid special characters. + */ + 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', + 'execute_callback' => function( $input ) { + return array( 'success' => true ); + }, + 'permission_callback' => '__return_true', + ) + ); + + // Test valid special characters (hyphen, forward slash) + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test-hyphen/ability' ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Data provider for invalid ability names. + * + * @return array + */ + public function 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. + * + * @dataProvider 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/v2/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. + * @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/v2/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 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. + * + * @dataProvider 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/v2/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 ) { + // Check that reasonable defaults were used + $data = $response->get_data(); + $this->assertIsArray( $data ); + } + } +} diff --git a/tests/unit/REST/WPRESTAbilitiesRunControllerTest.php b/tests/unit/REST/WPRESTAbilitiesRunControllerTest.php new file mode 100644 index 00000000..3e154365 --- /dev/null +++ b/tests/unit/REST/WPRESTAbilitiesRunControllerTest.php @@ -0,0 +1,946 @@ +user->create( + array( + 'role' => 'editor', + ) + ); + + self::$no_permission_user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + ) + ); + } + + /** + * Set up before each test. + */ + public function set_up(): void { + parent::set_up(); + + global $wp_rest_server; + $this->server = $wp_rest_server = new WP_REST_Server(); + do_action( 'rest_api_init' ); + + do_action( '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 { + foreach ( wp_get_abilities() as $ability ) { + if ( str_starts_with( $ability->get_name(), 'test/' ) ) { + wp_unregister_ability( $ability->get_name() ); + } + } + + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Register test abilities for testing. + */ + private function register_test_abilities(): void { + // Tool ability (POST only) + wp_register_ability( + 'test/calculator', + array( + 'label' => 'Calculator', + 'description' => 'Performs calculations', + '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' => function ( array $input ) { + return $input['a'] + $input['b']; + }, + 'permission_callback' => function ( array $input ) { + return current_user_can( 'edit_posts' ); + }, + 'meta' => array( + 'type' => 'tool', + ), + ) + ); + + // Resource ability (GET only) + wp_register_ability( + 'test/user-info', + array( + 'label' => 'User Info', + 'description' => 'Gets user information', + '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' => 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' => function ( array $input ) { + return is_user_logged_in(); + }, + 'meta' => array( + 'type' => 'resource', + ), + ) + ); + + // Ability with contextual permissions + wp_register_ability( + 'test/restricted', + array( + 'label' => 'Restricted Action', + 'description' => 'Requires specific input for permission', + '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' => function ( array $input ) { + return 'Success: ' . $input['data']; + }, + 'permission_callback' => function ( array $input ) { + // Only allow if secret matches + return isset( $input['secret'] ) && 'valid_secret' === $input['secret']; + }, + 'meta' => array( + 'type' => 'tool', + ), + ) + ); + + // Ability that returns null + wp_register_ability( + 'test/null-return', + array( + 'label' => 'Null Return', + 'description' => 'Returns null', + 'execute_callback' => function () { + return null; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'type' => 'tool', + ), + ) + ); + + // Ability that returns WP_Error + wp_register_ability( + 'test/error-return', + array( + 'label' => 'Error Return', + 'description' => 'Returns error', + 'execute_callback' => function () { + return new WP_Error( 'test_error', 'This is a test error' ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'type' => 'tool', + ), + ) + ); + + // Ability with invalid output + wp_register_ability( + 'test/invalid-output', + array( + 'label' => 'Invalid Output', + 'description' => 'Returns invalid output', + 'output_schema' => array( + 'type' => 'number', + ), + 'execute_callback' => function () { + return 'not a number'; // Invalid - schema expects number + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'type' => 'tool', + ), + ) + ); + + // Resource ability for query params testing + wp_register_ability( + 'test/query-params', + array( + 'label' => 'Query Params Test', + 'description' => 'Tests query parameter handling', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'param1' => array( 'type' => 'string' ), + 'param2' => array( 'type' => 'integer' ), + ), + ), + 'execute_callback' => function ( array $input ) { + return $input; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'type' => 'resource', + ), + ) + ); + } + + /** + * Test executing a tool ability with POST. + */ + public function test_execute_tool_ability_post(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/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 resource ability with GET. + */ + public function test_execute_resource_ability_get(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/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 HTTP method validation for tool abilities. + */ + public function test_tool_ability_requires_post(): void { + wp_register_ability( + 'test/open-tool', + array( + 'label' => 'Open Tool', + 'description' => 'Tool with no permission requirements', + 'execute_callback' => function () { + return 'success'; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'type' => 'tool', + ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/open-tool/run' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 405, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_invalid_method', $data['code'] ); + $this->assertStringContainsString( 'Tool abilities require POST', $data['message'] ); + } + + /** + * Test HTTP method validation for resource abilities. + */ + public function test_resource_ability_requires_get(): void { + // Try POST on a resource ability (should fail) + $request = new WP_REST_Request( 'POST', '/wp/v2/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->assertEquals( 405, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_invalid_method', $data['code'] ); + $this->assertStringContainsString( 'Resource abilities require GET', $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 'rest_ability_execution_failed'. + * + * @expectedIncorrectUsage WP_Ability::validate_output + */ + public function test_output_validation(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/invalid-output/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array() ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 500, $response->get_status() ); + $data = $response->get_data(); + + $this->assertEquals( 'rest_ability_execution_failed', $data['code'] ); + $this->assertStringContainsString( 'Ability execution failed', $data['message'] ); + } + + /** + * Test permission check for execution. + */ + public function test_execution_permission_denied(): void { + wp_set_current_user( self::$no_permission_user_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/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( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_cannot_execute', $data['code'] ); + } + + /** + * Test contextual permission check. + */ + public function test_contextual_permission_check(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/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 of null return from ability. + */ + public function test_null_return_handling(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/null-return/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array() ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 500, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_execution_failed', $data['code'] ); + $this->assertStringContainsString( 'Ability execution failed', $data['message'] ); + } + + /** + * Test handling of WP_Error return from ability. + */ + public function test_wp_error_return_handling(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/error-return/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array() ) ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 500, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_execution_failed', $data['code'] ); + $this->assertEquals( 'This is a test error', $data['message'] ); + } + + /** + * Test non-existent ability returns 404. + * + * @expectedIncorrectUsage WP_Abilities_Registry::get_registered + */ + public function test_execute_non_existent_ability(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/non/existent/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array() ) ); + + $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. + */ + public function test_run_endpoint_schema(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/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. + */ + public function test_invalid_json_in_post_body(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/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. + */ + public function test_get_request_with_nested_input_array(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/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. + */ + public function test_get_request_with_non_array_input(): void { + $request = new WP_REST_Request( 'GET', '/wp/v2/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. + */ + public function test_post_request_with_non_array_input(): void { + $request = new WP_REST_Request( 'POST', '/wp/v2/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. + * @expectedIncorrectUsage WP_Ability::validate_output + */ + 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', + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'status' => array( + 'type' => 'string', + 'enum' => array( 'success', 'failure' ), + ), + ), + 'required' => array( 'status' ), + ), + 'execute_callback' => function( $input ) { + // Return invalid output that doesn't match schema + return array( 'wrong_field' => 'value' ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'type' => 'tool' ), + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/strict-output/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => array() ) ) ); + + $response = $this->server->dispatch( $request ); + + // Should return error when output validation fails + $this->assertEquals( 500, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_ability_execution_failed', $data['code'] ); + } + + /** + * Test ability with invalid input that fails validation. + * @expectedIncorrectUsage WP_Ability::validate_input + */ + 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', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'required_field' => array( + 'type' => 'string', + ), + ), + 'required' => array( 'required_field' ), + ), + 'execute_callback' => function( $input ) { + return array( 'status' => 'success' ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'type' => 'tool' ), + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/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 (403 due to permission check) + $this->assertEquals( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_cannot_execute', $data['code'] ); + } + + /** + * Test ability type not set defaults to tool. + */ + public function test_ability_without_type_defaults_to_tool(): void { + // Register ability without type in meta. + wp_register_ability( + 'test/no-type', + array( + 'label' => 'No Type', + 'description' => 'Ability without type', + 'execute_callback' => function( $input ) { + return array( 'executed' => true ); + }, + 'permission_callback' => '__return_true', + 'meta' => array(), // No type specified + ) + ); + + // Should require POST (default tool behavior) + $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/no-type/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/v2/abilities/test/no-type/run' ); + $post_request->set_header( 'Content-Type', 'application/json' ); + $post_request->set_body( wp_json_encode( array( 'input' => array() ) ) ); + + $post_response = $this->server->dispatch( $post_request ); + $this->assertEquals( 200, $post_response->get_status() ); + } + + /** + * Test permission check with null permission callback. + */ + public function test_permission_check_passes_when_callback_not_set(): void { + // Register ability without permission callback. + wp_register_ability( + 'test/no-permission-callback', + array( + 'label' => 'No Permission Callback', + 'description' => 'Ability without permission callback', + 'execute_callback' => function( $input ) { + return array( 'executed' => true ); + }, + 'meta' => array( 'type' => 'tool' ), + // No permission_callback set + ) + ); + + wp_set_current_user( 0 ); // Not logged in + + $request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/no-permission-callback/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'input' => array() ) ) ); + + $response = $this->server->dispatch( $request ); + + // Should succeed when no permission callback is set + $this->assertEquals( 200, $response->get_status() ); + + // Restore user for other tests + wp_set_current_user( self::$user_id ); + } + + /** + * Test edge case with empty input for both GET and POST. + */ + public function test_empty_input_handling(): void { + // Register abilities for empty input testing + wp_register_ability( + 'test/resource-empty', + array( + 'label' => 'Resource Empty', + 'description' => 'Resource with empty input', + 'execute_callback' => function( $input ) { + return array( 'input_was_empty' => empty( $input ) ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'type' => 'resource' ), + ) + ); + + wp_register_ability( + 'test/tool-empty', + array( + 'label' => 'Tool Empty', + 'description' => 'Tool with empty input', + 'execute_callback' => function( $input ) { + return array( 'input_was_empty' => empty( $input ) ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'type' => 'tool' ), + ) + ); + + // Test GET with no input parameter + $get_request = new WP_REST_Request( 'GET', '/wp/v2/abilities/test/resource-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'] ); + + // Test POST with no body + $post_request = new WP_REST_Request( 'POST', '/wp/v2/abilities/test/tool-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 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. + * + * @dataProvider 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/v2/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. + */ + 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', + 'execute_callback' => function( $input ) { + return array( 'echo' => $input ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'type' => 'tool' ), + ) + ); + + $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/v2/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. + */ + 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', + 'execute_callback' => function( $input ) { + return array( 'echo' => $input ); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'type' => 'tool' ), + ) + ); + + $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/v2/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 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. + * + * @dataProvider 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', + 'execute_callback' => function() { + return array( 'success' => true ); + }, + 'permission_callback' => '__return_true', // No permission requirements + 'meta' => array( 'type' => 'tool' ), + ) + ); + + $request = new WP_REST_Request( $method, '/wp/v2/abilities/test/method-test/run' ); + $response = $this->server->dispatch( $request ); + + // Tool abilities should only accept POST, so these should return 405 + $this->assertEquals( 405, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_invalid_method', $data['code'] ); + } + + /** + * Test OPTIONS method handling. + */ + public function test_options_method_handling(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/abilities/test/calculator/run' ); + $response = $this->server->dispatch( $request ); + // OPTIONS requests return 200 with allowed methods + $this->assertEquals( 200, $response->get_status() ); + } + +}