diff --git a/lib/annotations.php b/lib/annotations.php new file mode 100644 index 00000000000000..0e62a6d2335ebd --- /dev/null +++ b/lib/annotations.php @@ -0,0 +1,22 @@ + false, + 'delete_with_user' => false, + 'hierarchical' => true, + 'supports' => array( + 'author', + 'editor', + 'custom-fields', + ), + 'show_in_rest' => true, + 'rest_base' => 'annotations', + 'rest_controller_class' => 'WP_REST_Annotations_Controller', + + 'map_meta_cap' => true, + 'capabilities' => array( + // Meta caps. + 'read_post' => 'read_post', + 'edit_post' => 'edit_post', + 'delete_post' => 'delete_post', + + // Primitive/meta caps. + 'create_posts' => 'edit_posts', + + // Primitive caps used outside map_meta_cap(). + 'edit_posts' => 'edit_posts', + 'edit_others_posts' => 'edit_others_posts', + 'publish_posts' => 'edit_posts', // non-default. + 'read_private_posts' => 'read_private_posts', + + // Primitive caps used in map_meta_cap(). + 'read' => 'edit_posts', // non-default. + 'delete_posts' => 'delete_posts', + 'delete_private_posts' => 'delete_private_posts', + 'delete_published_posts' => 'delete_posts', // non-default. + 'delete_others_posts' => 'delete_others_posts', + 'edit_private_posts' => 'edit_private_posts', + 'edit_published_posts' => 'edit_posts', // non-default. + ), + ) ); + + add_filter( 'user_has_cap', array( __CLASS__, 'on_user_has_cap' ), 10, 4 ); + add_action( 'delete_post', array( __CLASS__, 'on_delete_post' ), 10, 1 ); + } + + /** + * Maybe filter a user's capabilities when checking annotation meta caps. + * + * An annotation is a post type, so the checks below are in addition to those registered with the post type. + * Here, we're focused on meta cap checks and the annotation's parent post ID, because an annotation can be a + * child of all other post types. Make sure the non-annotation parent post can be annotated by the user. + * + * @since [version] + * @access public + * + * @param array $filtered_caps Associative array of user's filtered caps; e.g., ['edit_posts' => true, ...]. + * @param array $required_map_caps Mapped capabilities for possible underlying meta capability. + * @param array $has_cap_args Numerically indexed array arranged by WP_User::has_cap(). + * @param WP_User $user The user object. + */ + public static function on_user_has_cap( $filtered_caps, $required_map_caps, $has_cap_args, $user ) { + $original_cap = $has_cap_args[0]; // Possible meta cap. + $meta_cap_obj_id = isset( $has_cap_args[2] ) ? $has_cap_args[2] : 0; + + if ( $meta_cap_obj_id instanceof WP_Post ) { + $meta_cap_obj_id = $meta_cap_obj_id->ID; + } + $meta_cap_obj_id = absint( $meta_cap_obj_id ); + + // From map_meta_cap(). + $relevant_meta_caps = array( + 'read_post', + 'read_page', + + 'edit_post', + 'edit_page', + 'publish_post', + + 'delete_post', + 'delete_page', + + 'add_post_meta', + 'edit_post_meta', + 'delete_post_meta', + ); + + // Only dealing with meta caps. + if ( ! $meta_cap_obj_id || ! in_array( $original_cap, $relevant_meta_caps, true ) ) { + return $filtered_caps; + } + + // Only dealing with annotations. + $post = get_post( $meta_cap_obj_id ); + if ( ! $post || $post->post_type !== self::$post_type ) { + return $filtered_caps; + } + + // Check if user can annotate the parent post ID. + if ( ! self::user_can_annotate_parent_post( $post, $user ) ) { + return array(); // Deny; revoke all caps in this check. + } + + return $filtered_caps; + } + + /** + * Checks if a user can annotate a parent post. + * + * @since [version] + * @access public + * + * @param int|WP_Post|null $post Post ID or object. Defaults to current post. + * @param int|WP_User|null $user User ID or object. Defaults to current user. + * @param bool $is_parent Set to true if the $post *is* the parent post. + * + * @return bool True if $post is an annotation, + * the annotation has a parent post ID, + * and the user can annotate the parent post ID. + * + * Or, $is_parent is true, + * $post is not an annotation, + * and the user can annotate the $post. + * + * @see on_user_has_cap() + * @see WP_REST_Annotations_Controller + */ + public static function user_can_annotate_parent_post( $post = null, $user = null, $is_parent = false ) { + if ( null === $post ) { + $post = get_post(); + } elseif ( is_int( $post ) && $post > 0 ) { + $post = get_post( $post ); + } + if ( ! ( $post instanceof WP_Post ) ) { + return false; + } + + if ( null === $user ) { + $user = wp_get_current_user(); + } elseif ( is_int( $user ) && $user > 0 ) { + $user = get_user_by( 'id', $user ); + } + if ( ! ( $user instanceof WP_User ) ) { + return false; + } + + if ( ! $is_parent && $post->post_type !== self::$post_type ) { + return false; // If it's not a known parent, require an annotation. + } elseif ( $is_parent && $post->post_type === self::$post_type ) { + return false; // If it's a known parent, it shouldn't be an annotation. + } + + if ( $is_parent ) { + $parent_post_id = $post->ID; + $parent_post = $post; // It *is* the parent. + $parent_post_type = get_post_type_object( $parent_post->post_type ); + } else { + $parent_post_id = (int) get_post_meta( $post->ID, '_parent_post_id', true ); + $parent_post = $parent_post_id ? get_post( $parent_post_id ) : null; + $parent_post_type = $parent_post ? get_post_type_object( $parent_post->post_type ) : null; + } + + if ( $parent_post_id && $parent_post && $parent_post_type ) { + /* + * If they can edit the parent. + */ + if ( $user->has_cap( $parent_post_type->cap->edit_post, $parent_post->ID ) ) { + return true; + } + + /* + * Or, if the only reason they can't edit is because + * they can't edit_published_posts; e.g., a Contributor. + */ + if ( (int) $parent_post->post_author === $user->ID + && in_array( $parent_post->post_status, array( 'publish', 'future' ), true ) + && ! $user->has_cap( $parent_post_type->cap->edit_published_posts ) + && $user->has_cap( $parent_post_type->cap->edit_posts ) + ) { + return true; + } + } + + return false; + } + + /** + * Delete all of a post's annotations whenever its `parent_post_id` is permanently deleted from the database. + * + * @since [version] + * @access public + * + * @param int $post_id Post ID that will be deleted. + */ + public static function on_delete_post( $post_id ) { + $post = get_post( $post_id ); + + if ( ! $post || $post->post_type === self::$post_type ) { + return; // Only dealing with non-annotation types. + } + + $query = new WP_Query(); + $annotation_ids = $query->query( array( + 'fields' => 'ids', + 'post_type' => self::$post_type, + 'post_status' => array_keys( get_post_stati() ), + 'ignore_sticky_posts' => true, + 'no_found_rows' => true, + 'suppress_filters' => true, + 'posts_per_page' => -1, + 'meta_query' => array( + 'key' => '_parent_post_id', + 'value' => $post->ID, + ), + ) ); + + foreach ( $annotation_ids as $annotation_id ) { + wp_delete_post( $annotation_id, true ); + } + } + + /** + * Registers additional fields. + * + * @since [version] + * @access public + */ + public static function register_additional_rest_fields() { + $contexts = array( 'view', 'edit' ); + + /* + * Register additional REST API fields. + */ + + register_rest_field( self::$post_type, 'parent_post_id', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'update_callback' => array( __CLASS__, 'on_update_additional_rest_field' ), + 'schema' => array( + 'required' => true, + 'type' => 'integer', + 'context' => $contexts, + 'description' => __( 'Parent post ID.', 'gutenberg' ), + ), + ) ); + + register_rest_field( self::$post_type, 'author_meta', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'schema' => array( + 'readonly' => true, + 'type' => 'object', + 'context' => $contexts, + 'description' => __( 'Author metadata.', 'gutenberg' ), + + 'properties' => array( + 'display_name' => array( + 'type' => 'string', + 'context' => $contexts, + 'description' => __( 'Display name.', 'gutenberg' ), + ), + 'image_url' => array( + 'type' => 'string', + 'context' => $contexts, + 'description' => __( 'Square avatar image URL.', 'gutenberg' ), + ), + ), + ), + ) ); + + register_rest_field( self::$post_type, 'annotator', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'update_callback' => array( __CLASS__, 'on_update_additional_rest_field' ), + 'schema' => array( + 'type' => 'string', + 'context' => $contexts, + 'description' => sprintf( + // translators: %s is a regular expression pattern to clarify data requirements. + __( 'Annotator (plugin, service, other). Requires a non-numeric slug: %s', 'gutenberg' ), + '^[a-z][a-z0-9_-]*[a-z0-9]$' + ), + ), + ) ); + + register_rest_field( self::$post_type, 'annotator_meta', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'update_callback' => array( __CLASS__, 'on_update_additional_rest_field' ), + 'schema' => array( + 'type' => 'object', + 'context' => $contexts, + 'description' => __( 'Annotator metadata.', 'gutenberg' ), + + 'properties' => array( + 'display_name' => array( + 'type' => 'string', + 'context' => $contexts, + 'description' => __( 'Display name.', 'gutenberg' ), + ), + 'image_url' => array( + 'type' => 'string', + 'context' => $contexts, + 'description' => __( 'Square avatar image URL.', 'gutenberg' ), + ), + ), + 'additionalProperties' => true, + ), + ) ); + + register_rest_field( self::$post_type, 'selection', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'update_callback' => array( __CLASS__, 'on_update_additional_rest_field' ), + 'schema' => array( + 'type' => 'object', + 'context' => $contexts, + 'description' => __( 'Block selection range(s), if applicable.', 'gutenberg' ), + + 'properties' => array( + 'ranges' => array( + 'type' => 'array', + 'context' => $contexts, + 'description' => __( 'One or more selection range(s).', 'gutenberg' ), + + 'items' => array( + 'type' => 'object', + 'context' => $contexts, + 'description' => __( 'Selection range.', 'gutenberg' ), + + 'properties' => array( + 'begin' => array( + 'type' => 'object', + 'context' => $contexts, + 'description' => __( 'Beginning of selection range.', 'gutenberg' ), + + 'properties' => array( + 'offset' => array( + 'type' => 'integer', + 'context' => $contexts, + 'description' => __( 'Offset.', 'gutenberg' ), + ), + ), + ), + 'end' => array( + 'type' => 'object', + 'context' => $contexts, + 'description' => __( 'End of selection range.', 'gutenberg' ), + + 'properties' => array( + 'offset' => array( + 'type' => 'integer', + 'context' => $contexts, + 'description' => __( 'Offset.', 'gutenberg' ), + ), + ), + ), + ), + ), + ), + ), + ), + ) ); + + register_rest_field( self::$post_type, 'substatus', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'update_callback' => array( __CLASS__, 'on_update_additional_rest_field' ), + 'schema' => array( + 'type' => 'string', + 'context' => $contexts, + 'enum' => self::$substatuses, + 'description' => __( 'Current substatus.', 'gutenberg' ), + ), + ) ); + + register_rest_field( self::$post_type, 'last_substatus_time', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'schema' => array( + 'readonly' => true, + 'type' => 'integer', + 'context' => $contexts, + 'description' => __( 'Last substatus change (GMT/UTC timestamp).', 'gutenberg' ), + ), + ) ); + + register_rest_field( self::$post_type, 'substatus_history', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'schema' => array( + 'readonly' => true, + 'type' => 'array', + 'context' => $contexts, + 'description' => __( 'Substatus history.', 'gutenberg' ), + + 'items' => array( + 'type' => 'object', + 'context' => $contexts, + 'description' => __( 'History entry.', 'gutenberg' ), + + 'properties' => array( + 'identity' => array( + 'type' => 'string', + 'context' => $contexts, + 'description' => sprintf( + // translators: %s is a regular expression pattern to clarify data requirements. + __( 'Identity (user or annotator). Numeric ID for a user, or a non-numeric slug for an annotator: %s', 'gutenberg' ), + '^([0-9]+|[a-z][a-z0-9_-]*[a-z0-9])$' + ), + ), + 'identity_meta' => array( + 'type' => 'object', + 'context' => $contexts, + 'description' => __( 'Identity metadata.', 'gutenberg' ), + + 'properties' => array( + 'display_name' => array( + 'type' => 'string', + 'context' => $contexts, + 'description' => __( 'Display name.', 'gutenberg' ), + ), + 'image_url' => array( + 'type' => 'string', + 'context' => $contexts, + 'description' => __( 'Square avatar image URL.', 'gutenberg' ), + ), + ), + ), + 'time' => array( + 'type' => 'integer', + 'context' => $contexts, + 'description' => __( 'When substatus changed (GMT/UTC timestamp).', 'gutenberg' ), + ), + 'old' => array( + 'type' => 'string', + 'context' => $contexts, + 'enum' => self::$substatuses, + 'description' => __( 'Old substatus.', 'gutenberg' ), + ), + 'new' => array( + 'type' => 'string', + 'context' => $contexts, + 'enum' => self::$substatuses, + 'description' => __( 'New substatus.', 'gutenberg' ), + ), + ), + ), + ), + ) ); + + /* + * Filters that further implement the fields above. + */ + + add_filter( 'rest_' . self::$post_type . '_collection_params', array( __CLASS__, 'on_rest_collection_params' ) ); + add_filter( 'rest_' . self::$post_type . '_query', array( __CLASS__, 'on_rest_collection_query' ), 10, 2 ); + } + + /** + * Adds additional collection parameters to WP_REST_Posts_Controller. + * + * @since [version] + * @access public + * + * @param array $params JSON Schema-formatted collection parameters. + * @return array Filtered JSON Schema-formatted collection parameters. + * + * @see register_additional_rest_fields() + */ + public static function on_rest_collection_params( $params ) { + $contexts = array( 'view', 'edit' ); + + $params['hierarchical'] = array( + 'type' => 'string', + 'description' => __( 'Results in hierarchical format?', 'gutenberg' ), + 'enum' => array( '', 'flat', 'threaded' ), + 'default' => '', + ); + + $params['parent_post_id'] = array( + 'type' => 'array', + 'description' => __( 'Limit result set to those with one or more parent post IDs.', 'gutenberg' ), + 'items' => array( + 'type' => 'integer', + 'context' => $contexts, + ), + 'default' => array(), + ); + + $params['annotator'] = array( + 'type' => 'array', + 'description' => __( 'Limit result set to those by one or more annotator IDs.', 'gutenberg' ), + 'items' => array( + 'type' => 'string', + 'context' => $contexts, + ), + 'default' => array(), + ); + + $params['substatus'] = array( + 'type' => 'array', + 'description' => __( 'Limit result set to those assigned one or more substatuses.', 'gutenberg' ), + 'items' => array( + 'type' => 'string', + 'context' => $contexts, + 'enum' => self::$substatuses, + ), + 'default' => array( '' ), + ); + + return $params; + } + + /** + * Queries additional collection parameters in WP_REST_Posts_Controller. + * + * @since [version] + * @access public + * + * @param array $query_vars WP_Query vars. + * @param WP_REST_Request $request REST API request. + * @return array Filtered query args. + * + * @see register_additional_rest_fields() + */ + public static function on_rest_collection_query( $query_vars, $request ) { + /* + * A hierarchical request sets post_parent to 0 by default. + * This behavior matches that found in WP_Comment_Query. + */ + if ( $request['hierarchical'] && ! $request['parent'] ) { + $query_vars['post_parent'] = 0; + } + + /* + * Build meta queries. + */ + $meta_queries = array(); + + $parent_post_ids = $request['parent_post_id']; + $parent_post_ids = $parent_post_ids ? (array) $parent_post_ids : array(); + $parent_post_ids = array_map( 'absint', $parent_post_ids ); + + if ( $parent_post_ids ) { + $meta_queries[] = array( + 'key' => '_parent_post_id', + 'value' => $parent_post_ids, + 'compare' => 'IN', + ); + } + + $annotators = $request['annotator']; + $annotators = $annotators ? (array) $annotators : array(); + $annotators = array_map( 'sanitize_key', $annotators ); + + if ( $annotators ) { + $meta_queries[] = array( + 'key' => '_annotator', + 'value' => $annotators, + 'compare' => 'IN', + ); + } + + $substatuses = $request['substatus']; + $substatuses = $substatuses ? (array) $substatuses : array(); + $substatuses = array_map( 'sanitize_key', $substatuses ); + + if ( $substatuses ) { + $meta_queries[] = array( + 'key' => '_substatus', + 'value' => $substatuses, + 'compare' => 'IN', + ); + } + + /* + * Preserve an existing meta query. + */ + if ( $meta_queries ) { + if ( ! empty( $query_vars['meta_query'] ) ) { + $query_vars['meta_query'] = array( + 'relation' => 'AND', + $query_vars['meta_query'], + array( + 'relation' => 'AND', + $meta_queries, + ), + ); + } else { + $query_vars['meta_query'] = array( + 'relation' => 'AND', + $meta_queries, + ); + } + } + + return $query_vars; + } + + /** + * Callback that gets an additional REST API field value. + * + * @since [version] + * @access public + * + * @param array|WP_Post $post Post (annotation). + * @param string $field Field name. + * @param WP_Rest_Request $request REST API request. + * @return mixed|null Current value, null otherwise. + * + * @see register_additional_rest_fields() + */ + public static function on_get_additional_rest_field( $post, $field, $request ) { + /* + * There is some inconsistency (array|WP_Post) in the REST API hooks. + * Double-checking the $post data type before we begin here. + */ + if ( is_array( $post ) ) { + if ( ! empty( $post['id'] ) ) { + $post = get_post( $post['id'] ); + } elseif ( ! empty( $post['ID'] ) ) { + $post = get_post( $post['ID'] ); + } + } + + $value = get_post_meta( $post->ID, '_' . $field, true ); + + switch ( $field ) { + case 'parent_post_id': + if ( is_string( $value ) || is_int( $value ) ) { + return absint( $value ); + } + return 0; + + case 'author_meta': + $defaults = array( + 'display_name' => '', + 'image_url' => '', + ); + + if ( is_array( $value ) ) { + return array_merge( $defaults, $value ); + } + return $defaults; + + case 'annotator': + if ( is_string( $value ) || is_int( $value ) ) { + return (string) $value; + } + return ''; + + case 'annotator_meta': + /** + * Filters default annotator meta. + * + * @since [version] + * + * @param array $defaults As [key => value] pairs. + * @param WP_Post $post Post (annotation). + * @param WP_Rest_Request $request Current REST API request data. + * @param string $ctx 'get' or 'update' context ('get' in this case). + * + * @see validate_rest_field_annotator_on_update() + */ + $defaults = apply_filters( 'gutenberg_rest_annotator_meta_defaults', array(), $post, $request, 'get' ); + $defaults += array( + 'display_name' => '', + 'image_url' => '', + ); + + if ( is_array( $value ) ) { + return array_merge( $defaults, $value ); + } + return $defaults; + + case 'selection': + return is_array( $value ) ? $value : array(); + + case 'substatus': + return is_string( $value ) ? $value : ''; + + case 'last_substatus_time': + return is_numeric( $value ) ? absint( $value ) : 0; + + case 'substatus_history': + return is_array( $value ) ? $value : array(); + + default: + return null; // To be clear. + } + } + + /** + * Callback that updates an additional REST API field value. + * + * @since [version] + * @access public + * + * @param string $value New value. + * @param array|WP_Post $post Post (annotation). + * @param string $field Field name. + * @param WP_Rest_Request $request REST API request. + * @return WP_Error|null Error on failure, null otherwise. + * + * @see register_additional_rest_fields() + */ + public static function on_update_additional_rest_field( $value, $post, $field, $request ) { + /* + * There is some inconsistency (array|WP_Post) in the REST API hooks. + * Double-checking the $post data type before we begin here. + */ + if ( is_array( $post ) ) { + if ( ! empty( $post['id'] ) ) { + $post = get_post( $post['id'] ); + } elseif ( ! empty( $post['ID'] ) ) { + $post = get_post( $post['ID'] ); + } + } + + switch ( $field ) { + case 'parent_post_id': + $parent_post_id = self::validate_rest_field_parent_post_id_on_update( $value, $post, $request ); + + if ( is_wp_error( $parent_post_id ) ) { + return $parent_post_id; + } + update_post_meta( $post->ID, '_' . $field, $parent_post_id ); + + break; + + case 'annotator': + $annotator = self::validate_rest_field_annotator_on_update( + $value, $request['annotator_meta'], $post, $request + ); + + if ( is_wp_error( $annotator ) ) { + return $annotator; + } + update_post_meta( $post->ID, '_' . $field, $annotator['id'] ); + + break; + + case 'annotator_meta': + $annotator = self::validate_rest_field_annotator_on_update( + $request['annotator'], $value, $post, $request + ); + + if ( is_wp_error( $annotator ) ) { + return $annotator; + } + update_post_meta( $post->ID, '_' . $field, $annotator['meta'] ); + + break; + + case 'selection': + $selection = self::validate_rest_field_selection_on_update( $value, $post, $request ); + + if ( is_wp_error( $selection ) ) { + return $selection; + } + update_post_meta( $post->ID, '_' . $field, $selection ); + + break; + + case 'substatus': + $substatus = self::validate_rest_field_substatus_on_update( $value, $post, $request ); + $old_substatus = (string) get_post_meta( $post->ID, '_' . $field, true ); + + if ( is_wp_error( $substatus ) ) { + return $substatus; + } + update_post_meta( $post->ID, '_' . $field, $substatus ); + self::maybe_update_rest_field_substatus_history( $substatus, $old_substatus, $post, $request ); + + break; + + default: + return self::rest_field_unexpected_update_error( $field ); + } + } + + /** + * Validates a parent post ID update. + * + * @since [version] + * @access protected + * + * @param int|string $parent_post_id Parent post ID. + * @param WP_Post $post Post (annotation). + * @param WP_Rest_Request $request REST API request. + * @return int|WP_Error Parent post ID, else WP_Error on validation failure. + * + * @see on_update_additional_rest_field() + */ + protected static function validate_rest_field_parent_post_id_on_update( $parent_post_id, $post, $request ) { + $error = self::rest_field_validation_update_error( 'parent_post_id' ); + + if ( (int) $parent_post_id <= 0 ) { + return $error; + } + $parent_post_id = (int) $parent_post_id; + $post = get_post( $parent_post_id ); + + if ( ! $post || $post->post_type === self::$post_type ) { + return $error; // Must be a child of a non-annotation post type. + } + + /* + * Parent permissions are checked in WP_REST_Annotations_Controller already. + */ + return $parent_post_id; + } + + /** + * Validates an annotator update in the REST API (ID *and* meta together). + * + * @since [version] + * @access protected + * + * @param string $id Arbitrary annotator ID. + * @param array $meta Annotator meta values. + * @param WP_Post $post Post (annotation). + * @param WP_Rest_Request $request REST API request. + * @return array|WP_Error Associative array (`id` and `meta`), else WP_Error on validation failure. + * + * @see on_update_additional_rest_field() + */ + protected static function validate_rest_field_annotator_on_update( $id, $meta, $post, $request ) { + $error = self::rest_field_validation_update_error( array( 'annotator', 'annotator_meta' ) ); + + if ( ! $id || ! is_string( $id ) ) { + return $error; + } elseif ( ! $meta || ! is_array( $meta ) ) { + return $error; + } + + $id = (string) $id; + $raw_id = $id; // Original value. + + $id = sanitize_key( $id ); + $id = mb_substr( trim( $id, '_-' ), 0, 250 ); + + /* + * Numeric IDs point to real WP_User's and we need to distinguish. + * So an annotator cannot use a numeric ID. Just start with a letter. + */ + if ( ! $id || is_numeric( $id ) || $id !== $raw_id ) { + return $error; + } + + /** + * Filters default annotator meta. + * + * @since [version] + * + * @param array $defaults As [key => value] pairs. + * @param WP_Post $post Post (annotation). + * @param WP_Rest_Request $request Current REST API request data. + * @param string $ctx 'get' or 'update' context ('update' in this case). + * + * @see on_get_additional_rest_field() + */ + $default_meta = apply_filters( 'gutenberg_rest_annotator_meta_defaults', array(), $post, $request, 'update' ); + $default_meta += array( + 'display_name' => '', + 'image_url' => '', + ); + + $raw_meta = $meta; // Original input meta. + + $existing_meta = get_post_meta( $post->ID, '_annotator_meta', true ); + $existing_meta = is_array( $existing_meta ) ? $existing_meta : array(); + + $meta = array_merge( $default_meta, $existing_meta, $meta ); + $meta = array_intersect_key( $meta, $default_meta ); + + if ( ! is_string( $meta['display_name'] ) ) { + return $error; + } + $meta['display_name'] = sanitize_text_field( $meta['display_name'] ); + $meta['display_name'] = mb_substr( $meta['display_name'], 0, 250 ); + + if ( ! $meta['display_name'] ) { + return $error; + } elseif ( $meta['display_name'] !== $raw_meta['display_name'] ) { + return $error; + } + + if ( ! is_string( $meta['image_url'] ) ) { + return $error; + } elseif ( ! wp_parse_url( $meta['image_url'] ) ) { + return $error; + } + + /** + * Allows for custom sanitization/validation handlers. + * + * Returning an array (possibly sanitized) allows you to check/set/approve the update values. + * Returning a WP_Error reports a validation failure, which is passed back to the REST API controller. + * + * @since [version] + * + * @param array $meta Proposed update as [key => value] pairs. + * @param WP_Post $post Post (annotation) being updated. + * @param WP_Rest_Request $request The current REST API request. + * @param string $id Arbitrary annotator ID. + * @param array $raw_meta Raw meta values in request. + * @param array $existing_meta Existing meta values. + * @param array $default_meta Default meta values. + */ + $meta = apply_filters( + 'gutenberg_rest_annotator_sanitize_validate_update', + $meta, $post, $request, $id, $raw_meta, $existing_meta, $default_meta + ); + + if ( is_wp_error( $meta ) ) { + return $meta; + } elseif ( ! is_array( $meta ) ) { + return $error; + } + + return compact( 'id', 'meta' ); + } + + /** + * Validates a selection update in the REST API. + * + * @since [version] + * @access protected + * + * @param array $selection Selection data. + * @param WP_Post $post Post (annotation). + * @param WP_Rest_Request $request REST API request. + * @return array|WP_Error Selection array, else WP_Error on validation failure. + * + * @see on_update_additional_rest_field() + */ + protected static function validate_rest_field_selection_on_update( $selection, $post, $request ) { + $error = self::rest_field_validation_update_error( 'selection' ); + + if ( ! $selection ) { + return array(); // Empty is OK. + } elseif ( ! is_array( $selection ) ) { + return $error; + } elseif ( ! isset( $selection['ranges'] ) ) { + return $error; + } elseif ( 1 !== count( array_keys( $selection ) ) ) { + return $error; + } + + foreach ( $selection['ranges'] as $range ) { + if ( ! is_array( $range ) ) { + return $error; + } elseif ( ! isset( $range['begin']['offset'], $range['end']['offset'] ) ) { + return $error; + } elseif ( ! is_int( $range['begin']['offset'] ) || ! is_int( $range['end']['offset'] ) ) { + return $error; + } elseif ( 2 !== count( array_keys( $range ) ) ) { + return $error; + } + } + + return $selection; + } + + /** + * Validates a substatus update. + * + * @since [version] + * @access protected + * + * @param string $substatus Substatus. + * @param WP_Post $post Post (annotation). + * @param WP_Rest_Request $request REST API request. + * @return string|WP_Error Substatus, else WP_Error on validation failure. + * + * @see on_update_additional_rest_field() + */ + protected static function validate_rest_field_substatus_on_update( $substatus, $post, $request ) { + if ( ! in_array( $substatus, self::$substatuses, true ) ) { + return self::rest_field_validation_update_error( 'substatus' ); + } + + return $substatus; + } + + /** + * Maybe update substatus history following a substatus change. + * + * @since [version] + * @access protected + * + * @param string $new New substatus. + * @param string $old Old substatus. + * @param WP_Post $post Post (annotation). + * @param WP_Rest_Request $request REST API request. + * + * @see on_update_additional_rest_field() + */ + protected static function maybe_update_rest_field_substatus_history( $new, $old, $post, $request ) { + if ( $new === $old ) { + return; // No change. + } + + $current_time = time(); + $user = wp_get_current_user(); + $new_history_entry = array(); // Initialize. + + $history = get_post_meta( $post->ID, '_substatus_history', true ); + $history = is_array( $history ) ? $history : array(); + + $annotator = self::validate_rest_field_annotator_on_update( + $request['annotator'], $request['annotator_meta'], $post, $request + ); + + if ( ! is_wp_error( $annotator ) ) { + $new_history_entry = array( + 'identity' => $annotator['id'], + 'identity_meta' => array( + 'display_name' => $annotator['meta']['display_name'], + 'image_url' => $annotator['meta']['image_url'], + ), + 'time' => $current_time, + 'old' => $old, + 'new' => $new, + ); + } elseif ( $user->exists() ) { + $new_history_entry = array( + 'identity' => (string) $user->ID, + 'identity_meta' => array( + 'display_name' => $user->display_name, + 'image_url' => get_avatar_url( $user->ID ), + ), + 'time' => $current_time, + 'old' => $old, + 'new' => $new, + ); + } + + if ( $new_history_entry ) { + /** + * Allows annotation substatus history length to be increased or decreased. + * + * @since [version] + * + * @param int $length Maximum substatus changes to remember in each annotation. + * By default, substatus history will remember the last 25 changes. + */ + $history_length = apply_filters( 'gutenberg_rest_annotation_substatus_history_length', 25 ); + + $history[] = $new_history_entry; + $history = array_slice( $history, -$history_length ); + + update_post_meta( $post->ID, '_last_substatus_time', $current_time ); + update_post_meta( $post->ID, '_substatus_history', $history ); + } + } + + /** + * Returns a new WP_Error suitable for REST API field, update, validation errors. + * + * @since [version] + * @access protected + * + * @param string|string[] $field The problematic field name(s). + * @return WP_Error WP_Error object instance. + */ + protected static function rest_field_validation_update_error( $field ) { + if ( is_array( $field ) ) { + $field = implode( ', ', array_map( 'strval', $field ) ); + } + $field = (string) $field; + + return new WP_Error( 'gutenberg_annotation_field_validation_update_failure', sprintf( + // translators: %s is a comma-delimited list of REST API field names associated with failure. + __( 'Failed to update: %s (validation failure).', 'gutenberg' ), $field + ), array( 'status' => 400 ) ); + } + + /** + * Returns a new WP_Error suitable for unexpected REST API field update errors. + * + * @since [version] + * @access protected + * + * @param string $field The problematic field name. + * @return WP_Error WP_Error object instance. + */ + protected static function rest_field_unexpected_update_error( $field ) { + if ( is_array( $field ) ) { + $field = implode( ', ', array_map( 'strval', $field ) ); + } + $field = (string) $field; + + return new WP_Error( 'gutenberg_annotation_field_unexpected_update_failure', sprintf( + // translators: %s is a comma-delimited list of REST API field names associated with failure. + __( 'Failed to update: %s (unexpected failure).', 'gutenberg' ), $field + ), array( 'status' => 400 ) ); + } +} diff --git a/lib/class-wp-rest-annotations-controller.php b/lib/class-wp-rest-annotations-controller.php new file mode 100644 index 00000000000000..e961ece715d5b5 --- /dev/null +++ b/lib/class-wp-rest-annotations-controller.php @@ -0,0 +1,529 @@ +namespace = 'gutenberg/v1'; // @codingStandardsIgnoreLine - PHPCS false positive on 'namespace'. + + } + + /** + * Registers REST API routes. + * + * @since [version] + * @access public + */ + public function register_routes() { + WP_Annotation_Utils::register_additional_rest_fields(); + + return parent::register_routes(); + } + + /** + * Creates an item. + * + * @since [version] + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. + */ + public function create_item( $request ) { + if ( ! $request['slug'] ) { + $request->set_param( 'slug', uniqid( 'a' ) ); + } + return parent::create_item( $request ); + } + + /** + * Retrieves a collection of items. + * + * @since [version] + * @access public + * + * @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_items( $request ) { + if ( ! $request['hierarchical'] ) { + return parent::get_items( $request ); + } + + /* + * Hierarchical response. + */ + $response = parent::get_items( $request ); + if ( is_wp_error( $response ) ) { + return $response; + } + return $this->fill_descendants( $request, $response ); + } + + /** + * Determines the allowed query_vars for a get_items() response and prepares them for WP_Query. + * + * Also stores prepared query vars in a class property for fill_descendants(). + * + * @since [version] + * @access protected + * + * @param array $prepared_args Optional. Prepared WP_Query arguments. + * @param WP_REST_Request $request Optional. Full details about the request. + * @return array Prepared query arguments. + */ + protected function prepare_items_query( $prepared_args = array(), $request = null ) { + $this->prepared_query_vars = parent::prepare_items_query( $prepared_args, $request ); + return $this->prepared_query_vars; + } + + /** + * Fetch descendants for posts in current response. + * + * @since [version] + * @access protected + * + * @param WP_REST_Request $request Full details about the request. + * @param WP_REST_Response $response Current response with posts whose descendants should be filled in. + * @return WP_REST_Response|WP_Error New response with posts + all of their descendants, WP_Error otherwise. + */ + protected function fill_descendants( $request, $response ) { + if ( ! $request['hierarchical'] ) { + return $response; + } + + /* + * Establish parent query vars to consider in cache algorithm below, + * by ignoring parent query vars that are not a factor when caching children. + * The more we can *safely* ignore, the better our cache hit-ratio will be. + */ + $parent_query_vars_to_ignore_in_level_cache_keys = array( + 'page', + 'paged', + 'offset', + 'nopaging', + 'posts_per_page', + 'posts_per_archive_page', + + 'fields', + 'no_found_rows', + + 'cache_results', + 'update_post_meta_cache', + 'update_post_term_cache', + 'lazy_load_term_meta', + ); + $parent_query_vars_in_level_cache_keys = array_diff_key( + $this->prepared_query_vars, + array_fill_keys( $parent_query_vars_to_ignore_in_level_cache_keys, null ) + ); + $level_cache_key_template = 'get_' . $this->post_type . '_child_ids:{%level_parent_id%}'; // Replace {%level_parent_id%}. + $level_cache_key_template .= ':' . md5( serialize( $parent_query_vars_in_level_cache_keys ) ); + $level_cache_key_template .= ':' . wp_cache_get_last_changed( 'posts' ); + + /* + * Establish child query vars as a mirror of parent query vars, minus a few + * that should simply be ignored when querying child descendants. + * + * Note: post__in is ignored in child queries so it's possible to query for specific parents + * that a block has a reference to, while not ignoring descendants of those parents. + */ + $parent_query_vars_to_ignore_when_querying_child_levels = array( + 'p', + 'page_id', + 'pagename', + 'attachment_id', + + 'post__in', + 'post_name__in', + + 'post_parent', + 'post_parent__in', + 'post_parent__not_in', + + 'page', + 'paged', + 'offset', + 'nopaging', + 'posts_per_page', + 'posts_per_archive_page', + + 'fields', + 'no_found_rows', + 'ignore_sticky_posts', + + 'cache_results', + 'update_post_meta_cache', + 'update_post_term_cache', + 'lazy_load_term_meta', + ); + $child_query_vars_template = array_diff_key( + $this->prepared_query_vars, + array_fill_keys( $parent_query_vars_to_ignore_when_querying_child_levels, null ) + ); + $child_query_vars_template['cache_results'] = true; + $child_query_vars_template['ignore_sticky_posts'] = true; + $child_query_vars_template['no_found_rows'] = true; + $child_query_vars_template['posts_per_page'] = -1; + + /* + * Retrieve an entire level of children at a time. + */ + $response_data = $response->get_data(); + $level = 0; + $levels = array( + $level => wp_list_pluck( $response_data, 'id' ), + ); + do { // While we have child IDs at current level. + + $level_child_ids = array(); + $level_uncached_parent_ids = array(); + $level_parent_ids = $levels[ $level ]; + + foreach ( $level_parent_ids as $level_parent_id ) { + $level_cache_key = str_replace( '{%level_parent_id%}', $level_parent_id, $level_cache_key_template ); + $level_parent_child_ids = wp_cache_get( $level_cache_key, $this->post_type ); + + if ( false !== $level_parent_child_ids ) { + $level_child_ids = array_merge( $level_child_ids, $level_parent_child_ids ); + } else { + $level_uncached_parent_ids[] = $level_parent_id; + } + } + + if ( $level_uncached_parent_ids ) { + $level_query = new WP_Query(); + $level_query_vars = $child_query_vars_template; + $level_query_vars['post_parent__in'] = $level_uncached_parent_ids; + + $level_posts = $level_query->query( $level_query_vars ); + $level_parent_map = array_fill_keys( $level_uncached_parent_ids, array() ); + + foreach ( $level_posts as $level_post ) { + $level_parent_map[ $level_post->post_parent ][] = $level_post->ID; + $level_child_ids[] = $level_post->ID; + } + foreach ( $level_parent_map as $level_parent_id => $level_parent_child_ids ) { + $level_cache_key = str_replace( '{%level_parent_id%}', $level_parent_id, $level_cache_key_template ); + wp_cache_set( $level_cache_key, $level_parent_child_ids, $this->post_type ); + } + } + + $level_child_ids = array_unique( $level_child_ids ); + $levels[ ++$level ] = $level_child_ids; + } while ( $level_child_ids ); + + /* + * Establish non-top-level descendants and prime post caches. + */ + for ( + $i = 1, + $c = count( $levels ), + $descendant_ids = array(); + $i < $c; + $i++ + ) { + $descendant_ids = array_merge( $descendant_ids, $levels[ $i ] ); + } + _prime_post_caches( $descendant_ids ); + + /* + * Flat array of all response data + descendants. + */ + $all_response_data = $response_data; + + foreach ( $descendant_ids as $descendant_id ) { + $descendant_post = get_post( $descendant_id ); + + if ( ! $descendant_post || ! $this->check_read_permission( $descendant_post ) ) { + continue; // Exclude in either case. + } + $descendant_response = $this->prepare_item_for_response( $descendant_post, $request ); + $all_response_data[] = $this->prepare_response_for_collection( $descendant_response ); + } + + /* + * If a threaded representation was requested, build tree. + */ + if ( 'threaded' === $request['hierarchical'] ) { + $refs = array(); + $threaded_response_data = array(); + + foreach ( $all_response_data as &$data ) { // By reference. + $data['children'] = array(); + + // If not in reference array, it's top level. + if ( ! isset( $refs[ $data['parent'] ] ) ) { + $threaded_response_data[] = &$data; + $refs[ $data['id'] ] = &$data; + + } else { // Add child by reference. + $refs[ $data['parent'] ]['children'][] = &$data; + $refs[ $data['id'] ] = &$data; + } + } + $all_response_data = $threaded_response_data; // Top-level. + } + + /* + * Update response data & return. + */ + $response->set_data( $all_response_data ); + + return $response; + } + + /** + * Checks if a given request has access to read. + * + * @since [version] + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return bool|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { + $parent_check = parent::get_items_permissions_check( $request ); + if ( true !== $parent_check ) { + return $parent_check; // parent *method* ;-). + } + + $post_type = get_post_type_object( $this->post_type ); + + /* + * Parent 'posts' (non-annotation). + */ + $parent_post_ids = $request['parent_post_id']; + $parent_post_ids = $parent_post_ids ? (array) $parent_post_ids : array(); + $parent_post_ids = array_map( 'absint', $parent_post_ids ); + + if ( ! $parent_post_ids && ! current_user_can( $post_type->cap->edit_others_posts ) ) { + return new WP_Error( 'gutenberg_annotations_cannot_list_all', __( 'Sorry, you are not allowed to read all annotations as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + foreach ( $parent_post_ids as $parent_post_id ) { + if ( ! WP_Annotation_Utils::user_can_annotate_parent_post( $parent_post_id, null, true ) ) { + return new WP_Error( 'gutenberg_annotations_cannot_list_parent_post', __( 'Sorry, you are not allowed to read annotations as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + + /* + * Parents 'annotations'. + */ + $parent_ids = $request['parent']; + $parent_ids = $parent_ids ? (array) $parent_ids : array(); + $parent_ids = array_map( 'absint', $parent_ids ); + + foreach ( $parent_ids as $key => $parent_id ) { + $parent = get_post( $parent_id ); + + if ( $parent && ! WP_Annotation_Utils::user_can_annotate_parent_post( $parent ) ) { + return new WP_Error( 'gutenberg_annotations_cannot_list_parent', __( 'Sorry, you are not allowed to read annotations as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + + /* + * May only list if you can edit. + */ + if ( current_user_can( $post_type->cap->edit_posts ) ) { + return true; + } + + return new WP_Error( 'gutenberg_annotations_cannot_list', __( 'Sorry, you are not allowed to read annotations as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + /** + * Checks if a given request has access to read. + * + * @since [version] + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return bool|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + $parent_check = parent::get_item_permissions_check( $request ); + if ( true !== $parent_check ) { + return $parent_check; + } + + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + if ( $this->check_read_permission( $post ) ) { + return true; + } + + return new WP_Error( 'gutenberg_annotation_cannot_read', __( 'Sorry, you are not allowed to read annotations as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + /** + * Checks if a given request has access to create. + * + * @since [version] + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return bool|WP_Error True if the request has access to create, WP_Error object otherwise. + */ + public function create_item_permissions_check( $request ) { + $parent_check = parent::create_item_permissions_check( $request ); + if ( true !== $parent_check ) { + return $parent_check; + } + + $parent_post_id = absint( $request['parent_post_id'] ); + $parent_post = $parent_post_id ? get_post( $parent_post_id ) : null; + + if ( ! $parent_post ) { + return new WP_Error( 'gutenberg_annotation_parent_post_required', __( 'Sorry, you must specify a valid parent post ID when creating an annotation.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + $post_type = get_post_type_object( $this->post_type ); + + if ( current_user_can( $post_type->cap->create_posts ) ) { + if ( WP_Annotation_Utils::user_can_annotate_parent_post( $parent_post, null, true ) ) { + return true; + } + } + + return new WP_Error( 'gutenberg_annotation_cannot_create', __( 'Sorry, you are not allowed to create the annotation as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + /** + * Checks if a given request has access to update. + * + * @since [version] + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return bool|WP_Error True if the request has access to update, WP_Error object otherwise. + */ + public function update_item_permissions_check( $request ) { + $parent_check = parent::update_item_permissions_check( $request ); + if ( true !== $parent_check ) { + return $parent_check; + } + + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + if ( $this->check_update_permission( $post ) ) { + return true; + } + + return new WP_Error( 'gutenberg_annotation_cannot_update', __( 'Sorry, you are not allowed to update the annotation as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + /** + * Checks if a given request has access to delete. + * + * @since [version] + * @access public + * + * @param WP_REST_Request $request Full details about the request. + * @return bool|WP_Error True if the request has access to delete, WP_Error object otherwise. + */ + public function delete_item_permissions_check( $request ) { + $parent_check = parent::delete_item_permissions_check( $request ); + if ( true !== $parent_check ) { + return $parent_check; + } + + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + if ( $this->check_delete_permission( $post ) ) { + return true; + } + + return new WP_Error( 'gutenberg_annotation_cannot_delete', __( 'Sorry, you are not allowed to delete the annotation as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + /** + * Checks if a post can be read. + * + * @since [version] + * @access public + * + * @param WP_Post $post Post object. + * @return bool Whether the post can be read. + */ + public function check_read_permission( $post ) { + $parent_check = parent::check_read_permission( $post ); + if ( true !== $parent_check ) { + return $parent_check; + } + + if ( ! ( $post instanceof WP_Post ) ) { + return false; + } + + $post_type = get_post_type_object( $post->post_type ); + if ( ! $post_type ) { + return false; + } + + if ( current_user_can( $post_type->cap->read_post, $post->ID ) ) { + return true; + } + + return false; + } +} diff --git a/lib/load.php b/lib/load.php index 0da31fddf577cc..044423e517802c 100644 --- a/lib/load.php +++ b/lib/load.php @@ -13,7 +13,10 @@ require dirname( __FILE__ ) . '/class-wp-block-type.php'; require dirname( __FILE__ ) . '/class-wp-block-type-registry.php'; require dirname( __FILE__ ) . '/class-wp-rest-blocks-controller.php'; +require dirname( __FILE__ ) . '/class-wp-annotation-utils.php'; +require dirname( __FILE__ ) . '/class-wp-rest-annotations-controller.php'; require dirname( __FILE__ ) . '/blocks.php'; +require dirname( __FILE__ ) . '/annotations.php'; require dirname( __FILE__ ) . '/client-assets.php'; require dirname( __FILE__ ) . '/compat.php'; require dirname( __FILE__ ) . '/plugin-compat.php'; diff --git a/lib/register.php b/lib/register.php index 56f0d809d9b9fa..94363c3d1adf5c 100644 --- a/lib/register.php +++ b/lib/register.php @@ -406,6 +406,8 @@ function gutenberg_register_post_types() { 'rest_base' => 'blocks', 'rest_controller_class' => 'WP_REST_Blocks_Controller', ) ); + + WP_Annotation_Utils::register_post_type(); } add_action( 'init', 'gutenberg_register_post_types' ); diff --git a/phpunit/class-annotations-test.php b/phpunit/class-annotations-test.php new file mode 100644 index 00000000000000..88826a2629ab6b --- /dev/null +++ b/phpunit/class-annotations-test.php @@ -0,0 +1,402 @@ +user->create( array( 'role' => $r ) ); + + self::$post_id[ "by_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $r ], + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Post by ' . $r, + 'post_content' => '

bold italic test post.

', + ) ); + + self::$post_id[ "draft_by_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $r ], + 'post_type' => 'post', + 'post_status' => 'draft', + 'post_title' => 'Draft by ' . $r, + 'post_content' => '

bold italic test draft.

', + ) ); + } + + foreach ( self::$roles as $r ) { + foreach ( self::$roles as $_r ) { + foreach ( array( + 'in_post_by' => self::$post_id[ "by_{$r}" ], + 'in_draft_by' => self::$post_id[ "draft_by_{$r}" ], + ) as $k => $_parent_post_id ) { + $common_annotation_meta = array( + '_parent_post_id' => $_parent_post_id, + '_selection' => array( + 'ranges' => array( + array( + 'begin' => array( + 'offset' => 0, + ), + 'end' => array( + 'offset' => 100, + ), + ), + ), + ), + '_annotator' => 'x-plugin', + '_annotator_meta' => array( + 'display_name' => 'X Plugin', + 'md5_email' => 'c8e0057f78fa5b54326cd437494b87e9', + ), + '_substatus' => '', + '_last_substatus_time' => 0, + '_substatus_history' => array(), + ); + + self::$anno_id[ "{$_r}:{$k}_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $_r ], + 'post_parent' => 0, + 'post_status' => 'publish', + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation.

', + 'meta_input' => $common_annotation_meta, + ) ); + + self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $_r ], + 'post_status' => 'publish', + 'post_parent' => self::$anno_id[ "{$_r}:{$k}_{$r}" ], + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation reply.

', + 'meta_input' => $common_annotation_meta, + ) ); + + self::$anno_id[ "{$_r}:__reply_{$k}_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $_r ], + 'post_status' => 'publish', + 'post_parent' => self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ], + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation reply.

', + 'meta_input' => $common_annotation_meta, + ) ); + } + } + } + } + + /** + * Delete fake data after our tests run. + */ + public static function wpTearDownAfterClass() { + foreach ( self::$roles as $r ) { + wp_delete_post( self::$post_id[ "by_{$r}" ] ); + wp_delete_post( self::$post_id[ "draft_by_{$r}" ] ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + wp_delete_post( self::$anno_id[ "{$_r}:{$k}_{$r}" ] ); + wp_delete_post( self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ] ); + wp_delete_post( self::$anno_id[ "{$_r}:__reply_{$k}_{$r}" ] ); + } + } + self::delete_user( self::$user_id[ $r ] ); + } + } + + /* + * Basic tests. + */ + + /** + * Check that we can get the annotation post type. + */ + public function test_get_post_type() { + $this->assertTrue( ! empty( WP_Annotation_Utils::$post_type ) ); + $this->assertTrue( is_string( WP_Annotation_Utils::$post_type ) ); + $this->assertSame( WP_Annotation_Utils::$post_type, gutenberg_annotation_post_type() ); + } + + /** + * Check that we can get annotation substatuses. + */ + public function test_get_substatuses() { + $this->assertContains( '', WP_Annotation_Utils::$substatuses ); + $this->assertContains( 'archived', WP_Annotation_Utils::$substatuses ); + } + + /** + * Check that we have necessary hooks for the post type. + */ + public function test_post_type_hooks() { + $this->assertNotEmpty( has_filter( 'user_has_cap', 'WP_Annotation_Utils::on_user_has_cap' ) ); + $this->assertNotEmpty( has_action( 'delete_post', 'WP_Annotation_Utils::on_delete_post' ) ); + } + + /* + * Test user permissions. + */ + + /** + * Check that nonexistent users have no access to annotations whatsoever. + */ + public function test_nonexistent_deny_permissions() { + $post_type = get_post_type_object( WP_Annotation_Utils::$post_type ); + $cap = $post_type->cap; // Shorter. + + $r = 'nonexistent'; + wp_set_current_user( 0 ); + + $this->assertSame( "{$r}:create_posts:false", "{$r}:create_posts:" . ( current_user_can( $cap->create_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_posts:false", "{$r}:edit_posts:" . ( current_user_can( $cap->edit_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:publish_posts:false", "{$r}:publish_posts:" . ( current_user_can( $cap->publish_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_posts:false", "{$r}:delete_posts:" . ( current_user_can( $cap->delete_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:read_private_posts:false", "{$r}:read_private_posts:" . ( current_user_can( $cap->read_private_posts ) ? 'true' : 'false' ) ); + + $this->assertSame( "{$r}:edit_others_posts:false", "{$r}:edit_others_posts:" . ( current_user_can( $cap->edit_others_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_private_posts:false", "{$r}:edit_private_posts:" . ( current_user_can( $cap->edit_private_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_published_posts:false", "{$r}:edit_published_posts:" . ( current_user_can( $cap->edit_published_posts ) ? 'true' : 'false' ) ); + + $this->assertSame( "{$r}:delete_others_posts:false", "{$r}:delete_others_posts:" . ( current_user_can( $cap->delete_others_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_published_posts:false", "{$r}:delete_published_posts:" . ( current_user_can( $cap->delete_published_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_private_posts:false", "{$r}:delete_private_posts:" . ( current_user_can( $cap->delete_private_posts ) ? 'true' : 'false' ) ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + $this->assertSame( "{$r}:read_post:{$k}_{$_r}:false", "$r:read_post:{$k}_{$_r}:" . ( current_user_can( $cap->read_post, self::$anno_id[ "{$_r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_post:{$k}_{$_r}:false", "$r:edit_post:{$k}_{$_r}:" . ( current_user_can( $cap->edit_post, self::$anno_id[ "{$_r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_post:{$k}_{$_r}:false", "$r:delete_post:{$k}_{$_r}:" . ( current_user_can( $cap->delete_post, self::$anno_id[ "{$_r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + } + } + } + + /** + * Check that subscribers have no access to annotations whatsoever. + */ + public function test_subscriber_deny_permissions() { + $post_type = get_post_type_object( WP_Annotation_Utils::$post_type ); + $cap = $post_type->cap; // Shorter. + + foreach ( array( 'subscriber' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + $this->assertSame( "{$r}:create_posts:false", "{$r}:create_posts:" . ( current_user_can( $cap->create_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_posts:false", "{$r}:edit_posts:" . ( current_user_can( $cap->edit_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:publish_posts:false", "{$r}:publish_posts:" . ( current_user_can( $cap->publish_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_posts:false", "{$r}:delete_posts:" . ( current_user_can( $cap->delete_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:read_private_posts:false", "{$r}:read_private_posts:" . ( current_user_can( $cap->read_private_posts ) ? 'true' : 'false' ) ); + + $this->assertSame( "{$r}:edit_others_posts:false", "{$r}:edit_others_posts:" . ( current_user_can( $cap->edit_others_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_private_posts:false", "{$r}:edit_private_posts:" . ( current_user_can( $cap->edit_private_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_published_posts:false", "{$r}:edit_published_posts:" . ( current_user_can( $cap->edit_published_posts ) ? 'true' : 'false' ) ); + + $this->assertSame( "{$r}:delete_others_posts:false", "{$r}:delete_others_posts:" . ( current_user_can( $cap->delete_others_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_published_posts:false", "{$r}:delete_published_posts:" . ( current_user_can( $cap->delete_published_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_private_posts:false", "{$r}:delete_private_posts:" . ( current_user_can( $cap->delete_private_posts ) ? 'true' : 'false' ) ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + $this->assertSame( "{$r}:read_post:{$k}_{$_r}:false", "$r:read_post:{$k}_{$_r}:" . ( current_user_can( $cap->read_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_post:{$k}_{$_r}:false", "$r:edit_post:{$k}_{$_r}:" . ( current_user_can( $cap->edit_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_post:{$k}_{$_r}:false", "$r:delete_post:{$k}_{$_r}:" . ( current_user_can( $cap->delete_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + } + } + } + } + + /** + * Check that admins and editors can access all annotations without restriction. + * Admins and editors can read, edit, delete, and otherwise manipulate any annotation. + */ + public function test_admin_editor_allow_permissions() { + $post_type = get_post_type_object( WP_Annotation_Utils::$post_type ); + $cap = $post_type->cap; // Shorter. + + foreach ( array( 'administrator', 'editor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + $this->assertSame( "{$r}:create_posts:true", "{$r}:create_posts:" . ( current_user_can( $cap->create_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_posts:true", "{$r}:edit_posts:" . ( current_user_can( $cap->edit_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:publish_posts:true", "{$r}:publish_posts:" . ( current_user_can( $cap->publish_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_posts:true", "{$r}:delete_posts:" . ( current_user_can( $cap->delete_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:read_private_posts:true", "{$r}:read_private_posts:" . ( current_user_can( $cap->read_private_posts ) ? 'true' : 'false' ) ); + + $this->assertSame( "{$r}:edit_others_posts:true", "{$r}:edit_others_posts:" . ( current_user_can( $cap->edit_others_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_private_posts:true", "{$r}:edit_private_posts:" . ( current_user_can( $cap->edit_private_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_published_posts:true", "{$r}:edit_published_posts:" . ( current_user_can( $cap->edit_published_posts ) ? 'true' : 'false' ) ); + + $this->assertSame( "{$r}:delete_others_posts:true", "{$r}:delete_others_posts:" . ( current_user_can( $cap->delete_others_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_published_posts:true", "{$r}:delete_published_posts:" . ( current_user_can( $cap->delete_published_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_private_posts:true", "{$r}:delete_private_posts:" . ( current_user_can( $cap->delete_private_posts ) ? 'true' : 'false' ) ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + $this->assertSame( "{$r}:read_post:{$k}_{$_r}:true", "$r:read_post:{$k}_{$_r}:" . ( current_user_can( $cap->read_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_post:{$k}_{$_r}:true", "$r:edit_post:{$k}_{$_r}:" . ( current_user_can( $cap->edit_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_post:{$k}_{$_r}:true", "$r:delete_post:{$k}_{$_r}:" . ( current_user_can( $cap->delete_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + } + } + } + } + + /** + * Check that authors and contributors are able to read, edit, and delete + * annotations in their own published posts and their own drafts. + */ + public function test_author_allow_permissions() { + $post_type = get_post_type_object( WP_Annotation_Utils::$post_type ); + $cap = $post_type->cap; // Shorter. + + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r !== $_r ) { + continue; // Skip over other roles. + } + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + $this->assertSame( "{$r}:read_post:{$k}_{$_r}:true", "$r:read_post:{$k}_{$_r}:" . ( current_user_can( $cap->read_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_post:{$k}_{$_r}:true", "$r:edit_post:{$k}_{$_r}:" . ( current_user_can( $cap->edit_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_post:{$k}_{$_r}:true", "$r:delete_post:{$k}_{$_r}:" . ( current_user_can( $cap->delete_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + } + } + } + } + + /** + * Check that authors and contributors are unable to access annotations + * in a post that was drafted or published by 'someone else' other than them. + */ + public function test_author_contributor_deny_permissions() { + $post_type = get_post_type_object( WP_Annotation_Utils::$post_type ); + $cap = $post_type->cap; // Shorter. + + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r === $_r ) { + continue; // Skip over their own here. + } + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + $this->assertSame( "{$r}:read_post:{$k}_{$_r}:false", "$r:read_post:{$k}_{$_r}:" . ( current_user_can( $cap->read_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_post:{$k}_{$_r}:false", "$r:edit_post:{$k}_{$_r}:" . ( current_user_can( $cap->edit_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_post:{$k}_{$_r}:false", "$r:delete_post:{$k}_{$_r}:" . ( current_user_can( $cap->delete_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + } + } + } + } + + /* + * Test post annotation deletion. + */ + + /** + * Check that permanently deleting a post erases all of its annotations. + */ + public function test_delete_post_annotations() { + $post_id = $this->factory->post->create( array( + 'post_author' => self::$user_id['editor'], + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Post by editor.', + 'post_content' => '

bold italic test post.

', + ) ); + $this->assertInternalType( 'int', $post_id ); + $this->assertGreaterThan( 0, $post_id ); + + for ( $i = 0; $i < 3; $i++ ) { + $annotation_id = $this->factory->post->create( array( + 'post_author' => self::$user_id['editor'], + 'post_parent' => 0, + 'post_status' => 'publish', + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation.

', + 'meta_input' => array( '_parent_post_id' => $post_id ), + ) ); + $this->assertInternalType( 'int', $annotation_id ); + $this->assertGreaterThan( 0, $annotation_id ); + } + wp_delete_post( $post_id, true ); + + $query = new WP_Query(); + $annotation_ids = $query->query( array( + 'fields' => 'ids', + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_status' => array_keys( get_post_stati() ), + 'ignore_sticky_posts' => true, + 'no_found_rows' => true, + 'suppress_filters' => true, + 'posts_per_page' => -1, + 'meta_query' => array( + 'key' => '_parent_post_id', + 'value' => $post_id, + ), + ) ); + + $this->assertEmpty( $annotation_ids ); + } +} diff --git a/phpunit/class-rest-annotations-controller-test.php b/phpunit/class-rest-annotations-controller-test.php new file mode 100644 index 00000000000000..fa2fcfd53c1378 --- /dev/null +++ b/phpunit/class-rest-annotations-controller-test.php @@ -0,0 +1,993 @@ +user->create( array( 'role' => $r ) ); + + self::$post_id[ "by_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $r ], + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Post by ' . $r, + 'post_content' => '

bold italic test post.

', + ) ); + + self::$post_id[ "draft_by_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $r ], + 'post_type' => 'post', + 'post_status' => 'draft', + 'post_title' => 'Draft by ' . $r, + 'post_content' => '

bold italic test draft.

', + ) ); + } + + foreach ( self::$roles as $r ) { + foreach ( self::$roles as $_r ) { + foreach ( array( + 'in_post_by' => self::$post_id[ "by_{$r}" ], + 'in_draft_by' => self::$post_id[ "draft_by_{$r}" ], + ) as $k => $_parent_post_id ) { + $common_annotation_meta = array( + '_parent_post_id' => $_parent_post_id, + '_selection' => array( + 'ranges' => array( + array( + 'begin' => array( + 'offset' => 0, + ), + 'end' => array( + 'offset' => 100, + ), + ), + ), + ), + '_annotator' => 'x-plugin', + '_annotator_meta' => array( + 'display_name' => 'X Plugin', + 'md5_email' => 'c8e0057f78fa5b54326cd437494b87e9', + ), + '_substatus' => '', + '_last_substatus_time' => 0, + '_substatus_history' => array(), + ); + + self::$anno_id[ "{$_r}:{$k}_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $_r ], + 'post_parent' => 0, + 'post_status' => 'publish', + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation.

', + 'meta_input' => $common_annotation_meta, + ) ); + + self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $_r ], + 'post_status' => 'publish', + 'post_parent' => self::$anno_id[ "{$_r}:{$k}_{$r}" ], + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation reply.

', + 'meta_input' => $common_annotation_meta, + ) ); + + self::$anno_id[ "{$_r}:__reply_{$k}_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $_r ], + 'post_status' => 'publish', + 'post_parent' => self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ], + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation reply.

', + 'meta_input' => $common_annotation_meta, + ) ); + } + } + } + } + + /** + * Delete fake data after our tests run. + */ + public static function wpTearDownAfterClass() { + foreach ( self::$roles as $r ) { + wp_delete_post( self::$post_id[ "by_{$r}" ] ); + wp_delete_post( self::$post_id[ "draft_by_{$r}" ] ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + wp_delete_post( self::$anno_id[ "{$_r}:{$k}_{$r}" ] ); + wp_delete_post( self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ] ); + wp_delete_post( self::$anno_id[ "{$_r}:__reply_{$k}_{$r}" ] ); + } + } + self::delete_user( self::$user_id[ $r ] ); + } + } + + /** + * Check post data via parent: WP_Test_REST_Post_Type_Controller_Testcase. + */ + protected function check_post_data( $post, $data, $context, $links ) { + return parent::check_post_data( $post, $data, $context, array() ); + + if ( $links ) { + $links = test_rest_expand_compact_links( $links ); + $this->assertSame( $links['self'][0]['href'], rest_url( self::$rest_ns_base . '/' . $data['id'] ) ); + $this->assertSame( $links['collection'][0]['href'], rest_url( self::$rest_ns_base ) ); + $this->assertSame( $links['about'][0]['href'], rest_url( self::$rest_ns_base . '/' . $data['type'] ) ); + } + } + + /* + * Basic tests. + */ + + /** + * Check that our routes got registered properly. + */ + public function test_register_routes() { + $routes = $this->server->get_routes(); + + $this->assertArrayHasKey( self::$rest_ns_base, $routes ); + $this->assertCount( 2, $routes[ self::$rest_ns_base ] ); + + $this->assertArrayHasKey( self::$rest_ns_base . '/(?P[\d]+)', $routes ); + $this->assertCount( 3, $routes[ self::$rest_ns_base . '/(?P[\d]+)' ] ); + } + + /** + * Check that we've defined a JSON schema properly. + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', self::$rest_ns_base ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + + $this->assertSame( 24, count( $properties ) ); + $this->assertArrayHasKey( 'parent_post_id', $properties ); + $this->assertArrayHasKey( 'selection', $properties ); + $this->assertArrayHasKey( 'annotator', $properties ); + $this->assertArrayHasKey( 'annotator_meta', $properties ); + $this->assertArrayHasKey( 'substatus', $properties ); + $this->assertArrayHasKey( 'last_substatus_time', $properties ); + $this->assertArrayHasKey( 'substatus_history', $properties ); + } + + /** + * Check that our endpoints support the context param. + */ + public function test_context_param() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'OPTIONS', self::$rest_ns_base ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + + $request = new WP_REST_Request( 'OPTIONS', self::$rest_ns_base . '/' . self::$anno_id['editor:in_post_by_editor'] ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + /* + * Collection tests. + */ + + /** + * Check that we can GET a collection of annotations. + */ + public function test_get_items() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->assertSame( 100, count( $data ) ); + $this->check_get_posts_response( $response ); + } + + /** + * Check that we can GET a collection of annotations for a parent post ID. + */ + public function test_get_parent_post_id_items() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + $request->set_param( 'parent_post_id', self::$post_id['by_editor'] ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->assertSame( 15, count( $data ) ); + $this->check_get_posts_response( $response ); + } + + /** + * Check that we can GET a collection of all annotations with specific parent post IDs. + */ + public function test_get_parent_post_ids_plural_items() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'parent_post_id', array( + self::$post_id['by_editor'], + self::$post_id['by_author'], + self::$post_id['by_contributor'], + ) ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->assertSame( 45, count( $data ) ); + $this->check_get_posts_response( $response ); + } + + /** + * Check that we can GET a collection of all annotations + * with specific parent post IDs and specific parent annotation IDs. + */ + public function test_get_parent_post_ids_plural_parents_plural_items() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'parent_post_id', array( + self::$post_id['by_editor'], + self::$post_id['by_author'], + self::$post_id['by_contributor'], + ) ); + $request->set_param( 'parent', array( + self::$anno_id['editor:in_post_by_editor'], + self::$anno_id['author:in_post_by_author'], + self::$anno_id['contributor:in_post_by_contributor'], + ) ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->assertSame( 3, count( $data ) ); + $this->check_get_posts_response( $response ); + } + + /** + * Check that a collection of all annotations is flat by default. + */ + public function test_get_flat_items() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + $request->set_param( 'parent', array( 0 ) ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->assertSame( 50, count( $data ) ); + + foreach ( $data as $flat ) { + $this->assertArrayNotHasKey( 'children', $flat ); + } + $this->check_get_posts_response( $response ); + } + + /** + * Check that we can GET a collection in hierarchical=flat format. + */ + public function test_get_hierarchical_flat_items() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + $request->set_param( 'hierarchical', 'flat' ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->assertSame( 150, count( $data ) ); + + foreach ( $data as $flat ) { + $this->assertArrayNotHasKey( 'children', $flat ); + } + $this->check_get_posts_response( $response ); + } + + /** + * Check that we can GET a collection in hierarchical=threaded format. + */ + public function test_get_hierarchical_threaded_items() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + $request->set_param( 'hierarchical', 'threaded' ); + $request->set_param( 'parent', array( 0 ) ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->assertSame( 50, count( $data ) ); + + foreach ( $data as $level0 ) { + $this->assertArrayHasKey( 'children', $level0 ); + $this->assertSame( 1, count( $level0['children'] ) ); + + foreach ( $level0['children'] as $level1 ) { + $this->assertArrayHasKey( 'children', $level1 ); + $this->assertSame( 1, count( $level1['children'] ) ); + + foreach ( $level1['children'] as $level2 ) { + $this->assertArrayHasKey( 'children', $level2 ); + $this->assertSame( 0, count( $level2['children'] ) ); + } + } + } + $this->check_get_posts_response( $response ); + } + + /* + * Single item tests. + */ + + /** + * Check that we get a 404 when we try to GET a non-numeric annotation ID. + */ + public function test_get_item_not_found() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base . '/xyz' ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 404, $status ); + $this->assertSame( 'rest_no_route', $data['code'] ); + } + + /** + * Check that we get a 404 when we try to GET a nonexistent annotation ID. + */ + public function test_get_missing_item_not_found() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base . '/999' ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 404, $status ); + $this->assertSame( 'rest_post_invalid_id', $data['code'] ); + } + + /** + * Check that we can GET a single item in edit context. + */ + public function test_prepare_item() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( + 'GET', + self::$rest_ns_base . + '/' . self::$anno_id['editor:in_post_by_editor'] + ); + $request->set_param( 'context', 'edit' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->check_get_post_response( $response, 'edit' ); + } + + /** + * Check that we can GET a single annotation. + */ + public function test_get_item() { + wp_set_current_user( self::$user_id['author'] ); + + $request = new WP_REST_Request( + 'GET', + self::$rest_ns_base . + '/' . self::$anno_id['author:in_post_by_author'] + ); + $request->set_param( 'context', 'edit' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->check_get_post_response( $response, 'edit' ); + } + + /** + * Check that a user who can edit the posts of others can GET a single annotation by another user. + */ + public function test_get_item_by_other() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( + 'GET', + self::$rest_ns_base . + '/' . self::$anno_id['contributor:in_post_by_contributor'] + ); + $request->set_param( 'context', 'edit' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->check_get_post_response( $response, 'edit' ); + } + + /** + * Check that we can POST a single annotation. + */ + public function test_create_item() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'POST', self::$rest_ns_base ); + $request->set_body_params( array( + 'parent' => 0, + 'status' => 'publish', + 'author' => self::$user_id['editor'], + 'type' => WP_Annotation_Utils::$post_type, + 'content' => '

bold italic test annotation.

', + + 'parent_post_id' => self::$post_id['by_editor'], + + 'selection' => array( + 'ranges' => array( + array( + 'begin' => array( + 'offset' => 0, + ), + 'end' => array( + 'offset' => 100, + ), + ), + ), + ), + 'annotator' => 'x-plugin', + 'annotator_meta' => array( + 'display_name' => 'X Plugin', + 'md5_email' => 'c8e0057f78fa5b54326cd437494b87e9', + ), + 'substatus' => '', + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 201, $status ); + $this->check_create_post_response( $response ); + + wp_delete_post( $data['id'] ); + } + + /** + * Check that we can PUT a single annotation. + */ + public function test_update_item() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( + 'PUT', + self::$rest_ns_base . + '/' . self::$anno_id['contributor:in_post_by_contributor'] + ); + $request->set_body_params( array( + 'content' => 'hello world', + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->check_update_post_response( $response ); + } + + /** + * Test that a user is unable to PUT invalid fields. + */ + public function test_update_item_with_invalid_fields() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( + 'PUT', + self::$rest_ns_base . + '/' . self::$anno_id['editor:in_post_by_editor'] + ); + $request->set_body_params( array( + 'substatus' => 'foobar', + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 400, $status ); + $this->assertSame( 'rest_invalid_param', $data['code'] ); + } + + /** + * Check that we can DELETE a single annotation. + */ + public function test_delete_item() { + wp_set_current_user( self::$user_id['author'] ); + + $request = new WP_REST_Request( 'POST', self::$rest_ns_base ); + $request->set_body_params( array( + 'parent' => 0, + 'status' => 'publish', + 'author' => self::$user_id['author'], + 'content' => '

Test annotation.

', + 'parent_post_id' => self::$post_id['by_author'], + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->check_create_post_response( $response ); + + $request = new WP_REST_Request( 'DELETE', self::$rest_ns_base . '/' . $data['id'] ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + } + + /* + * Test user permissions. + */ + + /** + * Check that a nonexistent user can't GET a single annotation. + */ + public function test_nonexistent_get_item_deny_permissions() { + wp_set_current_user( 0 ); + + foreach ( self::$roles as $r ) { + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + $request = new WP_REST_Request( + 'GET', + self::$rest_ns_base . + '/' . self::$anno_id[ "{$r}:{$k}_{$_r}" ] + ); + $request->set_param( 'context', 'view' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + // see: . + $this->assertTrue( in_array( $status, array( 401, 403 ), true ) ); + $this->assertSame( 'rest_forbidden', $data['code'] ); + } + } + } + } + + /** + * Check that a subscriber can't GET a single annotation. + */ + public function test_subscriber_get_item_deny_permissions() { + wp_set_current_user( self::$user_id['subscriber'] ); + + foreach ( self::$roles as $r ) { + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + $request = new WP_REST_Request( + 'GET', + self::$rest_ns_base . + '/' . self::$anno_id[ "{$r}:{$k}_{$_r}" ] + ); + $request->set_param( 'context', 'view' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_forbidden', $data['code'] ); + } + } + } + } + + /** + * Check that nonexistent users can't GET (list) annotations whatsoever. + */ + public function test_nonexistent_get_items_deny_permissions() { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'gutenberg_annotations_cannot_list_all', $data['code'] ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'by', 'draft_by' ) as $k ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + $request->set_param( 'parent_post_id', self::$post_id[ "{$k}_{$_r}" ] ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'gutenberg_annotations_cannot_list_parent_post', $data['code'] ); + } + } + } + + /** + * Check that subscribers can't GET (list) annotations whatsoever. + */ + public function test_subscriber_get_items_deny_permissions() { + wp_set_current_user( self::$user_id['subscriber'] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'gutenberg_annotations_cannot_list_all', $data['code'] ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'by', 'draft_by' ) as $k ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + $request->set_param( 'parent_post_id', self::$post_id[ "{$k}_{$_r}" ] ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'gutenberg_annotations_cannot_list_parent_post', $data['code'] ); + } + } + } + + /** + * Check that nonexistent users are unable tp PUT an annotation. + */ + public function test_nonexistent_update_item_deny_permissions() { + wp_set_current_user( 0 ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + $request = new WP_REST_Request( + 'PUT', + self::$rest_ns_base . + '/' . self::$anno_id[ "{$_r}:${k}_${_r}" ] + ); + $request->set_param( 'substatus', 'archived' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_edit', $data['code'] ); + } + } + } + + /** + * Check that subscribers are unable tp PUT an annotation. + */ + public function test_subscribers_update_item_deny_permissions() { + foreach ( array( 'subscriber' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + $request = new WP_REST_Request( + 'PUT', + self::$rest_ns_base . + '/' . self::$anno_id[ "{$r}:${k}_${_r}" ] + ); + $request->set_param( 'substatus', 'archived' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_edit', $data['code'] ); + } + } + } + } + + /** + * Check that authors and contributors can't GET (list) the annotations of others. + */ + public function test_author_contributor_deny_get_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $_r === $r ) { + continue; // Skip their own. + } + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + $request->set_param( 'parent_post_id', self::$post_id[ "by_{$_r}" ] ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'gutenberg_annotations_cannot_list_parent_post', $data['code'] ); + } + } + } + + /** + * Check that authors and contributors can't GET (list) annotations + * for an array of parent post IDs, when any parent is owned by others. + */ + public function test_author_contributor_get_items_by_parent_post_id_deny_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r === $_r ) { + continue; // Skip their own role. + } + foreach ( array( 'by', 'draft_by' ) as $k ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + $request->set_param( 'parent_post_id', array( + self::$post_id[ "{$k}_{$r}" ], + self::$post_id[ "{$k}_{$_r}" ], + ) ); + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'gutenberg_annotations_cannot_list_parent_post', $data['code'] ); + } + } + } + } + + /** + * Test that authors and contributors are unable to PUT annotations in others' posts. + */ + public function test_author_contributor_update_item_in_others_post_deny_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r === $_r ) { + continue; // Skip their own. + } + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + $request = new WP_REST_Request( + 'PUT', + self::$rest_ns_base . + '/' . self::$anno_id[ "${r}:{$k}_{$_r}" ] + ); + $request->set_param( 'substatus', 'archived' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_edit', $data['code'] ); + } + } + } + } + + /** + * Test that authors and contributors are unable to DELETE annotations in others' posts. + */ + public function test_author_contributor_delete_item_in_others_post_deny_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r === $_r ) { + continue; // Skip their own. + } + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + $request = new WP_REST_Request( + 'DELETE', + self::$rest_ns_base . + '/' . self::$anno_id[ "${r}:{$k}_{$_r}" ] + ); + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_delete', $data['code'] ); + } + } + } + } + + /** + * Test that authors and contributors are able to PUT their own annotations in their own posts. + */ + public function test_author_contributor_update_item_in_own_draft_deny_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r !== $_r ) { + continue; // Skip others. + } + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + $request = new WP_REST_Request( + 'PUT', + self::$rest_ns_base . + '/' . self::$anno_id[ "${r}:{$k}_{$_r}" ] + ); + $request->set_body_params( array( + 'content' => 'hello world', + ) ); + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->check_update_post_response( $response ); + } + } + } + } + + /** + * Test that authors and contributors are able to DELETE their own annotations in their own posts. + */ + public function test_author_contributor_delete_item_in_own_draft_deny_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r !== $_r ) { + continue; // Skip others. + } + foreach ( array( 'in_post_by', 'in_draft_by' ) as $k ) { + $request = new WP_REST_Request( + 'DELETE', + self::$rest_ns_base . + '/' . self::$anno_id[ "${r}:{$k}_{$_r}" ] + ); + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + } + } + } + } +} diff --git a/phpunit/class-rest-blocks-controller-test.php b/phpunit/class-rest-blocks-controller-test.php index bf317ce670bd93..d45071adb39f67 100644 --- a/phpunit/class-rest-blocks-controller-test.php +++ b/phpunit/class-rest-blocks-controller-test.php @@ -174,7 +174,7 @@ public function test_delete_item() { array( 'deleted' => true, 'previous' => array( - 'id' => 8, + 'id' => self::$post_id, 'title' => 'My cool block', 'content' => '

Hello!

', ),