From 7508b55aea4ac44387cbf57083fed2409a0ba967 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 17 Jun 2026 12:10:57 +0200 Subject: [PATCH 01/20] Knowledge: Introduce the wp_knowledge custom post type. Register wp_knowledge, a private-by-default storage primitive for structured site knowledge, together with the wp_knowledge_type taxonomy. Both are built-in and headless (no admin UI); rows are managed through the REST API. Add WP_REST_Knowledge_Controller at /wp/v2/knowledge: reads require an authenticated user, collections are scoped to rows the current user can read, callers without the publish capability are limited to the private status, and new rows default to private. Revisions use the default controller; autosave endpoints are disabled. Synthesize a graded capability set through the user_has_cap filter: administrators manage all knowledge, while contributors and above create and manage their own private rows. Knowledge types are filterable via wp_knowledge_types(), with note as the save-time fallback. Trac ticket: https://core.trac.wordpress.org/ticket/65476 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/capabilities.php | 81 + src/wp-includes/default-filters.php | 5 + src/wp-includes/knowledge.php | 142 ++ src/wp-includes/post.php | 50 + .../class-wp-rest-knowledge-controller.php | 131 ++ src/wp-includes/taxonomy.php | 28 + src/wp-settings.php | 2 + .../phpunit/tests/knowledge/capabilities.php | 197 +++ tests/phpunit/tests/knowledge/postType.php | 141 ++ tests/phpunit/tests/knowledge/types.php | 142 ++ .../tests/rest-api/rest-schema-setup.php | 6 + .../rest-api/wpRestKnowledgeController.php | 278 ++++ tests/qunit/fixtures/wp-api-generated.js | 1327 ++++++++++++++++- 13 files changed, 2465 insertions(+), 65 deletions(-) create mode 100644 src/wp-includes/knowledge.php create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php create mode 100644 tests/phpunit/tests/knowledge/capabilities.php create mode 100644 tests/phpunit/tests/knowledge/postType.php create mode 100644 tests/phpunit/tests/knowledge/types.php create mode 100644 tests/phpunit/tests/rest-api/wpRestKnowledgeController.php diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 028e61ec414a8..d4f4991110b15 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1365,6 +1365,87 @@ function wp_maybe_grant_site_health_caps( $allcaps, $caps, $args, $user ) { return $allcaps; } +/** + * Filters the user capabilities to grant the `wp_knowledge` post type capabilities as necessary. + * + * The `wp_knowledge` post type uses a `knowledge`-prefixed capability set that is + * granted dynamically rather than stored on roles. Administrators (users with + * `manage_options`) receive every knowledge capability. Contributors, authors, + * and editors (users with `edit_posts`) may list and create knowledge rows and + * fully manage their own private rows; publishing knowledge and acting on other + * users' rows is reserved for administrators. Subscribers receive nothing and + * are stopped at the post-type door by the `read_knowledge` mapping. + * + * @since 7.1.0 + * + * @param bool[] $allcaps An array of all the user's capabilities. + * @param string[] $caps Required primitive capabilities for the requested capability. + * @param array $args { + * Arguments that accompany the requested capability check. + * + * @type string $0 Requested capability. + * @type int $1 Concerned user ID. + * @type mixed ...$2 Optional second and further parameters, typically object ID. + * } + * @param WP_User $user The user object. + * @return bool[] Filtered array of the user's capabilities. + */ +function wp_maybe_grant_knowledge_caps( array $allcaps, array $caps, array $args, WP_User $user ): array { + if ( ! empty( $allcaps['manage_options'] ) ) { + $allcaps['read_knowledge'] = true; + $allcaps['edit_knowledge'] = true; + $allcaps['edit_others_knowledge'] = true; + $allcaps['edit_published_knowledge'] = true; + $allcaps['edit_private_knowledge'] = true; + $allcaps['publish_knowledge'] = true; + $allcaps['delete_knowledge'] = true; + $allcaps['delete_others_knowledge'] = true; + $allcaps['delete_published_knowledge'] = true; + $allcaps['delete_private_knowledge'] = true; + $allcaps['read_private_knowledge'] = true; + + return $allcaps; + } + + if ( empty( $allcaps['edit_posts'] ) ) { + return $allcaps; + } + + /* + * Ambient floor for contributors and above: `read_knowledge` clears the + * post-type read check; `edit_knowledge` clears the create and ownership + * checks that do not pass a post ID. Per-post primitives are granted only + * in the per-post branch below. + */ + $allcaps['read_knowledge'] = true; + $allcaps['edit_knowledge'] = true; + + if ( ! isset( $args[0], $args[2] ) ) { + return $allcaps; + } + + if ( ! in_array( $args[0], array( 'edit_post', 'delete_post', 'read_post' ), true ) ) { + return $allcaps; + } + + $post = get_post( $args[2] ); + if ( + ! $post instanceof WP_Post || + 'wp_knowledge' !== $post->post_type || + (int) $post->post_author !== (int) $user->ID || + 'private' !== $post->post_status + ) { + return $allcaps; + } + + $allcaps['edit_private_knowledge'] = true; + $allcaps['delete_knowledge'] = true; + $allcaps['delete_private_knowledge'] = true; + $allcaps['read_private_knowledge'] = true; + + return $allcaps; +} + return; // Dummy gettext calls to get strings in the catalog. diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 783dd6fe42fc5..43329fc47f102 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -775,6 +775,7 @@ add_filter( 'user_has_cap', 'wp_maybe_grant_install_languages_cap', 1 ); add_filter( 'user_has_cap', 'wp_maybe_grant_resume_extensions_caps', 1 ); add_filter( 'user_has_cap', 'wp_maybe_grant_site_health_caps', 1, 4 ); +add_filter( 'user_has_cap', 'wp_maybe_grant_knowledge_caps', 1, 4 ); // Block templates post type and rendering. add_filter( 'render_block_context', '_block_template_render_without_post_block_context' ); @@ -788,6 +789,10 @@ // wp_navigation post type. add_filter( 'rest_wp_navigation_item_schema', array( 'WP_Navigation_Fallback', 'update_wp_navigation_post_schema' ) ); +// wp_knowledge post type. +add_action( 'save_post_wp_knowledge', '_wp_knowledge_ensure_default_type_term' ); +add_filter( 'wp_insert_term_data', '_wp_knowledge_maybe_map_term_label', 10, 2 ); + // Fluid typography. add_filter( 'render_block', 'wp_render_typography_support', 10, 2 ); diff --git a/src/wp-includes/knowledge.php b/src/wp-includes/knowledge.php new file mode 100644 index 0000000000000..9956bfddce88b --- /dev/null +++ b/src/wp-includes/knowledge.php @@ -0,0 +1,142 @@ + + */ +function wp_knowledge_types(): array { + /** + * Filters the knowledge types available on this site. + * + * @since 7.1.0 + * + * @param array $types { + * Slug-keyed map of knowledge types. + * + * @type array ...$0 { + * Data for a single knowledge type. + * + * @type string $title The human-readable label for the type. + * } + * } + * @phpstan-param array $types + */ + return apply_filters( + 'wp_knowledge_types', + array( + 'guideline' => array( + 'title' => _x( 'Guideline', 'knowledge type' ), + ), + 'memory' => array( + 'title' => _x( 'Memory', 'knowledge type' ), + ), + 'note' => array( + 'title' => _x( 'Note', 'knowledge type' ), + ), + ) + ); +} + +/** + * Assigns the `note` fallback term when a knowledge post is saved without a type. + * + * Hooked to the `save_post_wp_knowledge` action so that every knowledge row has + * at least one `wp_knowledge_type` term. Uses get_the_terms() so the check is + * served by the object term cache. + * + * @since 7.1.0 + * @access private + * + * @param int $post_id Saved post ID. + */ +function _wp_knowledge_ensure_default_type_term( int $post_id ): void { + if ( wp_is_post_revision( $post_id ) ) { + return; + } + + $terms = get_the_terms( $post_id, 'wp_knowledge_type' ); + if ( is_wp_error( $terms ) || ! empty( $terms ) ) { + return; + } + + /* + * Resolve to a term ID up front, creating the term on first use: + * wp_set_object_terms() interprets strings as names for hierarchical + * taxonomies, not slugs. + */ + $term = term_exists( 'note', 'wp_knowledge_type' ); + if ( ! $term ) { + $term = wp_insert_term( 'note', 'wp_knowledge_type' ); + if ( is_wp_error( $term ) ) { + return; + } + } + + wp_set_object_terms( $post_id, (int) $term['term_id'], 'wp_knowledge_type' ); +} + +/** + * Swaps a raw knowledge-type slug for its registered label on term creation. + * + * Hooked to the `wp_insert_term_data` filter. When wp_set_object_terms() is + * called with a slug that does not yet exist, wp_insert_term() fires and this + * filter runs after WordPress has computed both `name` and `slug`. A `name` + * equal to `slug` indicates the term was created from a raw slug (for example by + * wp_set_object_terms()) rather than from a user-provided label, so the label is + * replaced with the title from wp_knowledge_types(). Because term names are + * persisted in the database, the translated title is stored in the locale active + * when the term is created. + * + * @since 7.1.0 + * @access private + * + * @param array $data Term data to be inserted (keyed by column name). + * @param string $taxonomy Taxonomy slug. + * @return array Possibly modified term data. + * + * @phpstan-param array $data + * @phpstan-return array + */ +function _wp_knowledge_maybe_map_term_label( array $data, string $taxonomy ): array { + if ( 'wp_knowledge_type' !== $taxonomy ) { + return $data; + } + + if ( $data['name'] !== $data['slug'] ) { + return $data; + } + + $types = wp_knowledge_types(); + if ( isset( $types[ $data['slug'] ] ) ) { + $data['name'] = $types[ $data['slug'] ]['title']; + } + + return $data; +} diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 5c8dfdf39ce08..ac337b415ed82 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -16,6 +16,7 @@ * See {@see 'init'}. * * @since 2.9.0 + * @since 7.1.0 Added the `wp_knowledge` post type. */ function create_initial_post_types() { WP_Post_Type::reset_default_labels(); @@ -657,6 +658,55 @@ function create_initial_post_types() { ) ); + register_post_type( + 'wp_knowledge', + array( + 'labels' => array( + 'name' => _x( 'Knowledge', 'post type general name' ), + 'singular_name' => _x( 'Knowledge Item', 'post type singular name' ), + ), + 'public' => false, + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + 'hierarchical' => false, + /* + * Knowledge rows have no native post-type screens; they are managed + * through the REST API and consuming features, not the wp-admin UI. + */ + 'show_ui' => false, + 'map_meta_cap' => true, + /* + * "Knowledge" is a mass noun, so the singular and plural capability + * bases must differ: with both set to `knowledge`, the generated + * per-post meta caps (`edit_knowledge_item`) would collide with the + * primitive caps (`edit_knowledge`). The `*_knowledge_item` forms are + * never granted directly; `map_meta_cap()` resolves them onto the + * primitives, which `wp_maybe_grant_knowledge_caps()` synthesizes. + */ + 'capability_type' => array( 'knowledge_item', 'knowledge' ), + /* + * `read` is remapped so that subscribers (who hold the base `read` + * capability) are stopped at the post-type door. Every other + * primitive defaults to a `knowledge`-prefixed capability granted by + * `wp_maybe_grant_knowledge_caps()`. + */ + 'capabilities' => array( + 'read' => 'read_knowledge', + ), + 'query_var' => false, + 'rewrite' => false, + 'show_in_rest' => true, + 'rest_base' => 'knowledge', + 'rest_controller_class' => 'WP_REST_Knowledge_Controller', + 'supports' => array( 'title', 'editor', 'excerpt', 'author', 'revisions' ), + ) + ); + /* + * Disable autosave endpoints for knowledge. 'editor' support implies + * 'autosave', but knowledge is headless storage with no editor session, so + * the autosave REST routes have no consumer. Revision history is retained. + */ + remove_post_type_support( 'wp_knowledge', 'autosave' ); + register_post_status( 'publish', array( diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php new file mode 100644 index 0000000000000..14e3b4bd64def --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php @@ -0,0 +1,131 @@ +post_type ); + if ( ! current_user_can( $post_type->cap->read ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to view knowledge.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return parent::get_items_permissions_check( $request ); + } + + /** + * Determines the allowed query_vars for a get_items() response and prepares + * them for WP_Query. + * + * Scopes the collection to rows readable by the current user so that the + * total count and pagination headers reflect per-user visibility. + * + * @since 7.1.0 + * + * @param array $prepared_args Optional. Prepared WP_Query arguments. Default empty array. + * @param WP_REST_Request $request Optional. Full details about the request. + * @return array Items query arguments. + */ + protected function prepare_items_query( $prepared_args = array(), $request = null ) { + $query_args = parent::prepare_items_query( $prepared_args, $request ); + $query_args['perm'] = 'readable'; + + return $query_args; + } + + /** + * Checks if a knowledge row can be read. + * + * A row is readable only when the current user passes the `read_post` + * capability check, which accounts for the row's author and status. + * + * @since 7.1.0 + * + * @param WP_Post $post Post object. + * @return bool Whether the post can be read. + */ + public function check_read_permission( $post ) { + if ( ! current_user_can( 'read_post', $post->ID ) ) { + return false; + } + + return parent::check_read_permission( $post ); + } + + /** + * Determines validity and normalizes the given status parameter. + * + * Callers without the publish capability may only set the `private` status. + * + * @since 7.1.0 + * + * @param string $post_status The post status. + * @param WP_Post_Type $post_type The post type object. + * @return string|WP_Error Post status or WP_Error if not allowed. + */ + protected function handle_status_param( $post_status, $post_type ) { + if ( ! current_user_can( $post_type->cap->publish_posts ) ) { + if ( 'private' !== $post_status ) { + return new WP_Error( + 'rest_cannot_publish', + __( 'Sorry, you are only allowed to set knowledge to a private status.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return $post_status; + } + + return parent::handle_status_param( $post_status, $post_type ); + } + + /** + * Prepares a single knowledge row for create or update. + * + * New rows default to the `private` status when no status is supplied. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Request object. + * @return stdClass|WP_Error Post object or WP_Error. + */ + protected function prepare_item_for_database( $request ) { + if ( ! isset( $request['id'] ) && null === $request['status'] ) { + $request->set_param( 'status', 'private' ); + } + + return parent::prepare_item_for_database( $request ); + } +} diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 493d303068cf3..75fd6b5e02af1 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -19,6 +19,7 @@ * * @since 2.8.0 * @since 5.9.0 Added `'wp_template_part_area'` taxonomy. + * @since 7.1.0 Added `'wp_knowledge_type'` taxonomy. * * @global WP_Rewrite $wp_rewrite WordPress rewrite component. */ @@ -261,6 +262,33 @@ function create_initial_taxonomies() { 'show_tagcloud' => false, ) ); + + register_taxonomy( + 'wp_knowledge_type', + array( 'wp_knowledge' ), + array( + 'public' => false, + 'publicly_queryable' => false, + 'hierarchical' => true, + 'labels' => array( + 'name' => _x( 'Knowledge Types', 'taxonomy general name' ), + 'singular_name' => _x( 'Knowledge Type', 'taxonomy singular name' ), + ), + 'capabilities' => array( + 'manage_terms' => 'manage_options', + 'edit_terms' => 'edit_knowledge', + 'delete_terms' => 'manage_options', + 'assign_terms' => 'edit_knowledge', + ), + 'query_var' => false, + 'rewrite' => false, + 'show_ui' => false, + '_builtin' => true, + 'show_in_nav_menus' => false, + 'show_in_rest' => true, + 'show_admin_column' => true, + ) + ); } /** diff --git a/src/wp-settings.php b/src/wp-settings.php index d15310c665eae..2f19d56c35e3b 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -256,6 +256,7 @@ require ABSPATH . WPINC . '/class-wp-term.php'; require ABSPATH . WPINC . '/class-wp-term-query.php'; require ABSPATH . WPINC . '/class-wp-tax-query.php'; +require ABSPATH . WPINC . '/knowledge.php'; require ABSPATH . WPINC . '/update.php'; require ABSPATH . WPINC . '/canonical.php'; require ABSPATH . WPINC . '/shortcodes.php'; @@ -359,6 +360,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-families-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-faces-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-collections-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-knowledge-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-icons-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-view-config-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-categories-controller.php'; diff --git a/tests/phpunit/tests/knowledge/capabilities.php b/tests/phpunit/tests/knowledge/capabilities.php new file mode 100644 index 0000000000000..40f58827f4b96 --- /dev/null +++ b/tests/phpunit/tests/knowledge/capabilities.php @@ -0,0 +1,197 @@ +user->create( array( 'role' => $role ) ); + } + + self::$own_private = $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$users['contributor'], + ) + ); + + self::$own_published = $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'publish', + 'post_author' => self::$users['contributor'], + ) + ); + + self::$others_private = $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$users['author'], + ) + ); + } + + /** + * @ticket 65476 + */ + public function test_administrator_has_every_primitive() { + wp_set_current_user( self::$users['administrator'] ); + + $this->assertTrue( current_user_can( 'read_knowledge' ) ); + $this->assertTrue( current_user_can( 'edit_knowledge' ) ); + $this->assertTrue( current_user_can( 'edit_others_knowledge' ) ); + $this->assertTrue( current_user_can( 'publish_knowledge' ) ); + $this->assertTrue( current_user_can( 'delete_knowledge' ) ); + $this->assertTrue( current_user_can( 'delete_others_knowledge' ) ); + $this->assertTrue( current_user_can( 'read_private_knowledge' ) ); + } + + /** + * @ticket 65476 + */ + public function test_administrator_can_act_on_others_rows() { + wp_set_current_user( self::$users['administrator'] ); + + $this->assertTrue( current_user_can( 'edit_post', self::$others_private ) ); + $this->assertTrue( current_user_can( 'read_post', self::$others_private ) ); + $this->assertTrue( current_user_can( 'delete_post', self::$others_private ) ); + } + + /** + * @ticket 65476 + */ + public function test_subscriber_has_no_access() { + wp_set_current_user( self::$users['subscriber'] ); + + $this->assertFalse( current_user_can( 'read_knowledge' ) ); + $this->assertFalse( current_user_can( 'edit_knowledge' ) ); + } + + /** + * @ticket 65476 + */ + public function test_anonymous_has_no_access() { + wp_set_current_user( 0 ); + + $this->assertFalse( current_user_can( 'read_knowledge' ) ); + $this->assertFalse( current_user_can( 'edit_knowledge' ) ); + } + + /** + * @ticket 65476 + * + * @dataProvider data_contributor_level_roles + * + * @param string $role Role slug. + */ + public function test_contributor_level_ambient_floor( $role ) { + wp_set_current_user( self::$users[ $role ] ); + + // May list and create knowledge. + $this->assertTrue( current_user_can( 'read_knowledge' ), "$role should read_knowledge" ); + $this->assertTrue( current_user_can( 'edit_knowledge' ), "$role should edit_knowledge" ); + + // May not publish or act on other users' rows. + $this->assertFalse( current_user_can( 'publish_knowledge' ), "$role should not publish_knowledge" ); + $this->assertFalse( current_user_can( 'edit_others_knowledge' ), "$role should not edit_others_knowledge" ); + $this->assertFalse( current_user_can( 'delete_others_knowledge' ), "$role should not delete_others_knowledge" ); + } + + public function data_contributor_level_roles(): array { + return array( + 'contributor' => array( 'contributor' ), + 'author' => array( 'author' ), + 'editor' => array( 'editor' ), + ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_can_manage_own_private_row() { + wp_set_current_user( self::$users['contributor'] ); + + $this->assertTrue( current_user_can( 'edit_post', self::$own_private ) ); + $this->assertTrue( current_user_can( 'read_post', self::$own_private ) ); + $this->assertTrue( current_user_can( 'delete_post', self::$own_private ) ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_cannot_edit_own_published_row() { + wp_set_current_user( self::$users['contributor'] ); + + // Publishing is reserved for administrators, so an already-published + // row falls outside the per-post grant. + $this->assertFalse( current_user_can( 'edit_post', self::$own_published ) ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_cannot_act_on_others_rows() { + wp_set_current_user( self::$users['contributor'] ); + + $this->assertFalse( current_user_can( 'edit_post', self::$others_private ) ); + $this->assertFalse( current_user_can( 'read_post', self::$others_private ) ); + $this->assertFalse( current_user_can( 'delete_post', self::$others_private ) ); + } + + /** + * @ticket 65476 + */ + public function test_grant_does_not_apply_to_other_post_types() { + wp_set_current_user( self::$users['contributor'] ); + + $page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'private', + 'post_author' => self::$users['contributor'], + ) + ); + + // The knowledge per-post grant must not leak into other post types. + $this->assertFalse( current_user_can( 'edit_post', $page_id ) ); + } +} diff --git a/tests/phpunit/tests/knowledge/postType.php b/tests/phpunit/tests/knowledge/postType.php new file mode 100644 index 0000000000000..8bf82c76284b5 --- /dev/null +++ b/tests/phpunit/tests/knowledge/postType.php @@ -0,0 +1,141 @@ +assertTrue( post_type_exists( 'wp_knowledge' ) ); + } + + /** + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_is_builtin_and_private() { + $post_type = get_post_type_object( 'wp_knowledge' ); + + $this->assertInstanceOf( 'WP_Post_Type', $post_type ); + $this->assertTrue( $post_type->_builtin, '_builtin should be true' ); + $this->assertFalse( $post_type->public, 'public should be false' ); + $this->assertFalse( $post_type->show_ui, 'show_ui should be false' ); + $this->assertFalse( $post_type->hierarchical, 'hierarchical should be false' ); + } + + /** + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_rest_configuration() { + $post_type = get_post_type_object( 'wp_knowledge' ); + + $this->assertTrue( $post_type->show_in_rest, 'show_in_rest should be true' ); + $this->assertSame( 'knowledge', $post_type->rest_base ); + $this->assertSame( 'WP_REST_Knowledge_Controller', $post_type->rest_controller_class ); + } + + /** + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_supports() { + $this->assertTrue( post_type_supports( 'wp_knowledge', 'title' ) ); + $this->assertTrue( post_type_supports( 'wp_knowledge', 'editor' ) ); + $this->assertTrue( post_type_supports( 'wp_knowledge', 'excerpt' ) ); + $this->assertTrue( post_type_supports( 'wp_knowledge', 'author' ) ); + $this->assertTrue( post_type_supports( 'wp_knowledge', 'revisions' ) ); + } + + /** + * Revisions are supported and served by the default revisions controller. + * + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_supports_revisions_with_default_controller() { + $this->assertTrue( post_type_supports( 'wp_knowledge', 'revisions' ) ); + + $controller = get_post_type_object( 'wp_knowledge' )->get_revisions_rest_controller(); + $this->assertInstanceOf( 'WP_REST_Revisions_Controller', $controller ); + } + + /** + * Autosave support is removed, so no autosave endpoints are registered. + * + * Knowledge is headless storage with no editor session; `editor` support + * implies `autosave`, which is explicitly removed at registration. + * + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_does_not_support_autosaves() { + $this->assertFalse( post_type_supports( 'wp_knowledge', 'autosave' ) ); + $this->assertNull( get_post_type_object( 'wp_knowledge' )->get_autosave_rest_controller() ); + } + + /** + * The `read` capability is remapped so that the base `read` cap (held by + * subscribers) does not grant access to the post type. + * + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_read_capability_is_remapped() { + $post_type = get_post_type_object( 'wp_knowledge' ); + + $this->assertSame( 'read_knowledge', $post_type->cap->read ); + } + + /** + * "Knowledge" is a mass noun, so the per-post meta capabilities (derived + * from the singular base) must not collide with the primitive capabilities + * (derived from the plural base). A collision would make checks such as + * `current_user_can( 'edit_knowledge' )` ambiguous. + * + * @ticket 65476 + * @covers ::create_initial_post_types + */ + public function test_post_type_meta_caps_do_not_collide_with_primitives() { + $cap = get_post_type_object( 'wp_knowledge' )->cap; + + // Meta capabilities are derived from the singular `knowledge_item` base. + $this->assertSame( 'edit_knowledge_item', $cap->edit_post ); + $this->assertSame( 'read_knowledge_item', $cap->read_post ); + $this->assertSame( 'delete_knowledge_item', $cap->delete_post ); + + // Primitive capabilities are derived from the plural `knowledge` base. + $this->assertSame( 'edit_knowledge', $cap->edit_posts ); + $this->assertSame( 'edit_others_knowledge', $cap->edit_others_posts ); + $this->assertSame( 'publish_knowledge', $cap->publish_posts ); + $this->assertSame( 'read_private_knowledge', $cap->read_private_posts ); + + // The meta and primitive forms must be distinct. + $this->assertNotSame( $cap->edit_post, $cap->edit_posts ); + $this->assertNotSame( $cap->read_post, $cap->read_private_posts ); + $this->assertNotSame( $cap->delete_post, $cap->delete_posts ); + } + + /** + * @ticket 65476 + * @covers ::create_initial_taxonomies + */ + public function test_knowledge_type_taxonomy_is_attached() { + $this->assertTrue( taxonomy_exists( 'wp_knowledge_type' ) ); + $this->assertContains( 'wp_knowledge_type', get_object_taxonomies( 'wp_knowledge' ) ); + + $taxonomy = get_taxonomy( 'wp_knowledge_type' ); + $this->assertTrue( $taxonomy->hierarchical, 'taxonomy should be hierarchical' ); + $this->assertFalse( $taxonomy->public, 'taxonomy should not be public' ); + $this->assertTrue( $taxonomy->show_in_rest, 'taxonomy should be shown in REST' ); + } +} diff --git a/tests/phpunit/tests/knowledge/types.php b/tests/phpunit/tests/knowledge/types.php new file mode 100644 index 0000000000000..ff9921d8071e1 --- /dev/null +++ b/tests/phpunit/tests/knowledge/types.php @@ -0,0 +1,142 @@ +assertArrayHasKey( 'guideline', $types ); + $this->assertArrayHasKey( 'memory', $types ); + $this->assertArrayHasKey( 'note', $types ); + + $this->assertSame( 'Guideline', $types['guideline']['title'] ); + $this->assertSame( 'Memory', $types['memory']['title'] ); + $this->assertSame( 'Note', $types['note']['title'] ); + } + + /** + * @ticket 65476 + * @covers ::wp_knowledge_types + */ + public function test_types_are_filterable() { + $callback = static function ( $types ) { + $types['skill'] = array( 'title' => 'Skill' ); + return $types; + }; + + add_filter( 'wp_knowledge_types', $callback ); + $types = wp_knowledge_types(); + remove_filter( 'wp_knowledge_types', $callback ); + + $this->assertArrayHasKey( 'skill', $types ); + $this->assertSame( 'Skill', $types['skill']['title'] ); + } + + /** + * A knowledge row saved without a type term should fall back to `note`. + * + * @ticket 65476 + * @covers ::_wp_knowledge_ensure_default_type_term + */ + public function test_default_type_term_is_assigned_on_save() { + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + ) + ); + + $terms = wp_get_object_terms( $post_id, 'wp_knowledge_type', array( 'fields' => 'slugs' ) ); + + $this->assertSame( array( 'note' ), $terms ); + } + + /** + * A row that already carries a type term should keep it, not gain `note`. + * + * @ticket 65476 + * @covers ::_wp_knowledge_ensure_default_type_term + */ + public function test_existing_type_term_is_preserved_on_save() { + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + ) + ); + + // Assign a non-default term, replacing the `note` fallback from creation. + $term = wp_insert_term( 'memory', 'wp_knowledge_type' ); + wp_set_object_terms( $post_id, (int) $term['term_id'], 'wp_knowledge_type' ); + + // A subsequent save must not re-add the `note` fallback. + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'Updated knowledge', + ) + ); + + $terms = wp_get_object_terms( $post_id, 'wp_knowledge_type', array( 'fields' => 'slugs' ) ); + + $this->assertSame( array( 'memory' ), $terms ); + } + + /** + * A term lazily created from a registered slug gets the registered label. + * + * @ticket 65476 + * @covers ::_wp_knowledge_maybe_map_term_label + */ + public function test_registered_slug_term_gets_mapped_label() { + $term = wp_insert_term( 'guideline', 'wp_knowledge_type' ); + $this->assertNotWPError( $term ); + + $created = get_term( $term['term_id'], 'wp_knowledge_type' ); + + $this->assertSame( 'guideline', $created->slug ); + $this->assertSame( 'Guideline', $created->name ); + } + + /** + * A user-provided label (where name differs from the slug) is left intact. + * + * @ticket 65476 + * @covers ::_wp_knowledge_maybe_map_term_label + */ + public function test_custom_label_is_not_overwritten() { + $term = wp_insert_term( 'My Custom Type', 'wp_knowledge_type', array( 'slug' => 'guideline' ) ); + $this->assertNotWPError( $term ); + + $created = get_term( $term['term_id'], 'wp_knowledge_type' ); + + $this->assertSame( 'guideline', $created->slug ); + $this->assertSame( 'My Custom Type', $created->name ); + } + + /** + * The label mapping must not touch terms in other taxonomies. + * + * @ticket 65476 + * @covers ::_wp_knowledge_maybe_map_term_label + */ + public function test_label_mapping_is_scoped_to_knowledge_taxonomy() { + $term = wp_insert_term( 'guideline', 'category' ); + $this->assertNotWPError( $term ); + + $created = get_term( $term['term_id'], 'category' ); + + $this->assertSame( 'guideline', $created->name ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 2b99a10fecca2..9c26a1c4304ac 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -108,6 +108,10 @@ public function test_expected_routes_in_schema() { '/wp/v2/pages/(?P[\\d]+)/autosaves', '/wp/v2/pages/(?P[\\d]+)/autosaves/(?P[\\d]+)', '/wp/v2/pattern-directory/patterns', + '/wp/v2/knowledge', + '/wp/v2/knowledge/(?P[\\d]+)', + '/wp/v2/knowledge/(?P[\\d]+)/revisions', + '/wp/v2/knowledge/(?P[\\d]+)/revisions/(?P[\\d]+)', '/wp/v2/media', '/wp/v2/media/(?P[\\d]+)', '/wp/v2/media/(?P[\\d]+)/post-process', @@ -192,6 +196,8 @@ public function test_expected_routes_in_schema() { '/wp-site-health/v1/tests/authorization-header', '/wp-site-health/v1/tests/page-cache', '/wp-site-health/v1/directory-sizes', + '/wp/v2/wp_knowledge_type', + '/wp/v2/wp_knowledge_type/(?P[\d]+)', '/wp/v2/wp_pattern_category', '/wp/v2/wp_pattern_category/(?P[\d]+)', '/wp/v2/font-collections', diff --git a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php new file mode 100644 index 0000000000000..a4515f5ed4540 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php @@ -0,0 +1,278 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$contributor_id = $factory->user->create( array( 'role' => 'contributor' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + + self::$admin_private = $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$admin_id, + 'post_title' => 'Admin private knowledge', + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$contributor_id ); + self::delete_user( self::$subscriber_id ); + wp_delete_post( self::$admin_private, true ); + } + + /** + * Creates a private knowledge row for the given author. + * + * @param int $author_id Author user ID. + * @return int Post ID. + */ + private function create_knowledge_post( int $author_id ): int { + return self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => $author_id, + 'post_title' => 'Knowledge row', + ) + ); + } + + /** + * @ticket 65476 + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( '/wp/v2/knowledge', $routes ); + $this->assertArrayHasKey( '/wp/v2/knowledge/(?P[\d]+)', $routes ); + } + + /** + * @ticket 65476 + */ + public function test_context_param() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/knowledge' ); + $response = rest_get_server()->dispatch( $request ); + $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'] ); + } + + /** + * @ticket 65476 + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + + // The collection defaults to the `publish` status; knowledge rows are + // private by default, so list a published row here. + self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'publish', + 'post_author' => self::$admin_id, + 'post_title' => 'Published knowledge', + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotEmpty( $response->get_data() ); + } + + /** + * @ticket 65476 + */ + public function test_get_items_requires_authentication() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 401, $response->get_status() ); + + wp_set_current_user( self::$subscriber_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status() ); + } + + /** + * @ticket 65476 + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge/' . self::$admin_private ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( self::$admin_private, $response->get_data()['id'] ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_cannot_read_others_private_row() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge/' . self::$admin_private ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertContains( $response->get_status(), array( 401, 403 ) ); + } + + /** + * @ticket 65476 + */ + public function test_create_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); + $request->set_body_params( array( 'title' => 'Created by admin' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + $data = $response->get_data(); + // With no status supplied, new rows default to private rather than draft. + $this->assertSame( 'private', $data['status'] ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_create_defaults_to_private() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); + $request->set_body_params( array( 'title' => 'Created by contributor' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'private', $response->get_data()['status'] ); + } + + /** + * @ticket 65476 + */ + public function test_contributor_cannot_publish() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); + $request->set_body_params( + array( + 'title' => 'Attempted publish', + 'status' => 'publish', + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_publish', $response, 403 ); + } + + /** + * @ticket 65476 + */ + public function test_update_item() { + wp_set_current_user( self::$admin_id ); + + $post_id = $this->create_knowledge_post( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge/' . $post_id ); + $request->set_body_params( array( 'title' => 'Updated title' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'Updated title', $response->get_data()['title']['raw'] ); + } + + /** + * @ticket 65476 + */ + public function test_delete_item() { + wp_set_current_user( self::$admin_id ); + + $post_id = $this->create_knowledge_post( self::$admin_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/knowledge/' . $post_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( get_post( $post_id ) ); + } + + /** + * @ticket 65476 + */ + public function test_prepare_item() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge/' . self::$admin_private ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'id', $data ); + $this->assertArrayHasKey( 'title', $data ); + $this->assertArrayHasKey( 'status', $data ); + $this->assertArrayHasKey( 'author', $data ); + } + + /** + * @ticket 65476 + */ + public function test_get_item_schema() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/knowledge' ); + $response = rest_get_server()->dispatch( $request ); + $properties = $response->get_data()['schema']['properties']; + + $this->assertArrayHasKey( 'id', $properties ); + $this->assertArrayHasKey( 'title', $properties ); + $this->assertArrayHasKey( 'content', $properties ); + $this->assertArrayHasKey( 'excerpt', $properties ); + $this->assertArrayHasKey( 'status', $properties ); + $this->assertArrayHasKey( 'author', $properties ); + } +} diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 14a26f8301e3b..25ebf8a8b2299 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -8471,6 +8471,874 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/knowledge": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "allow_batch": { + "v1": true + }, + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "after": { + "description": "Limit response to posts published after a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "modified_after": { + "description": "Limit response to posts modified after a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "author": { + "description": "Limit result set to posts assigned to specific authors.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "author_exclude": { + "description": "Ensure result set excludes posts assigned to specific authors.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "before": { + "description": "Limit response to posts published before a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "modified_before": { + "description": "Limit response to posts modified before a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "include": { + "description": "Limit result set to specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "search_semantics": { + "description": "How to interpret the search input.", + "type": "string", + "enum": [ + "exact" + ], + "required": false + }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by post attribute.", + "type": "string", + "default": "date", + "enum": [ + "author", + "date", + "id", + "include", + "modified", + "parent", + "relevance", + "slug", + "include_slugs", + "title" + ], + "required": false + }, + "search_columns": { + "default": [], + "description": "Array of column names to be searched.", + "type": "array", + "items": { + "enum": [ + "post_title", + "post_content", + "post_excerpt" + ], + "type": "string" + }, + "required": false + }, + "slug": { + "description": "Limit result set to posts with one or more specific slugs.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "status": { + "default": "publish", + "description": "Limit result set to posts assigned one or more statuses.", + "type": "array", + "items": { + "enum": [ + "publish", + "future", + "draft", + "pending", + "private", + "trash", + "auto-draft", + "inherit", + "request-pending", + "request-confirmed", + "request-failed", + "request-completed", + "any" + ], + "type": "string" + }, + "required": false + }, + "tax_relation": { + "description": "Limit result set based on relationship between multiple taxonomies.", + "type": "string", + "enum": [ + "AND", + "OR" + ], + "required": false + }, + "wp_knowledge_type": { + "description": "Limit result set to items with specific terms assigned in the wp_knowledge_type taxonomy.", + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "title": "Term ID List", + "description": "Match terms with the listed IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "title": "Term ID Taxonomy Query", + "description": "Perform an advanced term query.", + "type": "object", + "properties": { + "terms": { + "description": "Term IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [] + }, + "include_children": { + "description": "Whether to include child terms in the terms limiting the result set.", + "type": "boolean", + "default": false + }, + "operator": { + "description": "Whether items must be assigned all or any of the specified terms.", + "type": "string", + "enum": [ + "AND", + "OR" + ], + "default": "OR" + } + }, + "additionalProperties": false + } + ], + "required": false + }, + "wp_knowledge_type_exclude": { + "description": "Limit result set to items except those with specific terms assigned in the wp_knowledge_type taxonomy.", + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "title": "Term ID List", + "description": "Match terms with the listed IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "title": "Term ID Taxonomy Query", + "description": "Perform an advanced term query.", + "type": "object", + "properties": { + "terms": { + "description": "Term IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [] + }, + "include_children": { + "description": "Whether to include child terms in the terms limiting the result set.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + } + ], + "required": false + } + } + }, + { + "methods": [ + "POST" + ], + "allow_batch": { + "v1": true + }, + "args": { + "date": { + "description": "The date the post was published, in the site's timezone.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "date_gmt": { + "description": "The date the post was published, as GMT.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "slug": { + "description": "An alphanumeric identifier for the post unique to its type.", + "type": "string", + "required": false + }, + "status": { + "description": "A named status for the post.", + "type": "string", + "enum": [ + "publish", + "future", + "draft", + "pending", + "private" + ], + "required": false + }, + "password": { + "description": "A password to protect access to the content and excerpt.", + "type": "string", + "required": false + }, + "title": { + "description": "The title for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Title for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML title for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "content": { + "description": "The content for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Content for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML content for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit" + ], + "readonly": true + }, + "block_version": { + "description": "Version of the content block format used by the post.", + "type": "integer", + "context": [ + "edit" + ], + "readonly": true + }, + "protected": { + "description": "Whether the content is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "author": { + "description": "The ID for the author of the post.", + "type": "integer", + "required": false + }, + "excerpt": { + "description": "The excerpt for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Excerpt for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML excerpt for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + }, + "protected": { + "description": "Whether the excerpt is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "template": { + "description": "The theme file to use to display the post.", + "type": "string", + "required": false + }, + "wp_knowledge_type": { + "description": "The terms assigned to the post in the wp_knowledge_type taxonomy.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/knowledge" + } + ] + } + }, + "/wp/v2/knowledge/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "excerpt_length": { + "description": "Override the default excerpt length.", + "type": "integer", + "required": false + }, + "password": { + "description": "The password for the post if it is password protected.", + "type": "string", + "required": false + } + } + }, + { + "methods": [ + "POST", + "PUT", + "PATCH" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "date": { + "description": "The date the post was published, in the site's timezone.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "date_gmt": { + "description": "The date the post was published, as GMT.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "slug": { + "description": "An alphanumeric identifier for the post unique to its type.", + "type": "string", + "required": false + }, + "status": { + "description": "A named status for the post.", + "type": "string", + "enum": [ + "publish", + "future", + "draft", + "pending", + "private" + ], + "required": false + }, + "password": { + "description": "A password to protect access to the content and excerpt.", + "type": "string", + "required": false + }, + "title": { + "description": "The title for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Title for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML title for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "content": { + "description": "The content for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Content for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML content for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit" + ], + "readonly": true + }, + "block_version": { + "description": "Version of the content block format used by the post.", + "type": "integer", + "context": [ + "edit" + ], + "readonly": true + }, + "protected": { + "description": "Whether the content is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "author": { + "description": "The ID for the author of the post.", + "type": "integer", + "required": false + }, + "excerpt": { + "description": "The excerpt for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Excerpt for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML excerpt for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + }, + "protected": { + "description": "Whether the excerpt is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "template": { + "description": "The theme file to use to display the post.", + "type": "string", + "required": false + }, + "wp_knowledge_type": { + "description": "The terms assigned to the post in the wp_knowledge_type taxonomy.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Whether to bypass Trash and force deletion.", + "required": false + } + } + } + ] + }, + "/wp/v2/knowledge/(?P[\\d]+)/revisions": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "include": { + "description": "Limit result set to specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by object attribute.", + "type": "string", + "default": "date", + "enum": [ + "date", + "id", + "include", + "relevance", + "slug", + "include_slugs", + "title" + ], + "required": false + } + } + } + ] + }, + "/wp/v2/knowledge/(?P[\\d]+)/revisions/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "id": { + "description": "Unique identifier for the revision.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "id": { + "description": "Unique identifier for the revision.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Required to be true, as revisions do not support trashing.", + "required": false + } + } + } + ] + }, "/wp/v2/types": { "namespace": "wp/v2", "methods": [ @@ -8622,23 +9490,219 @@ mockedApiResponse.Schema = { } ], "_links": { - "self": "http://example.org/index.php?rest_route=/wp/v2/taxonomies" + "self": "http://example.org/index.php?rest_route=/wp/v2/taxonomies" + } + }, + "/wp/v2/taxonomies/(?P[\\w-]+)": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "taxonomy": { + "description": "An alphanumeric identifier for the taxonomy.", + "type": "string", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + } + ] + }, + "/wp/v2/categories": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "allow_batch": { + "v1": true + }, + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "include": { + "description": "Limit result set to specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "asc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by term attribute.", + "type": "string", + "default": "name", + "enum": [ + "id", + "include", + "name", + "slug", + "include_slugs", + "term_group", + "description", + "count" + ], + "required": false + }, + "hide_empty": { + "description": "Whether to hide terms not assigned to any posts.", + "type": "boolean", + "default": false, + "required": false + }, + "parent": { + "description": "Limit result set to terms assigned to a specific parent.", + "type": "integer", + "required": false + }, + "post": { + "description": "Limit result set to terms assigned to a specific post.", + "type": "integer", + "default": null, + "required": false + }, + "slug": { + "description": "Limit result set to terms with one or more specific slugs.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + } + } + }, + { + "methods": [ + "POST" + ], + "allow_batch": { + "v1": true + }, + "args": { + "description": { + "description": "HTML description of the term.", + "type": "string", + "required": false + }, + "name": { + "description": "HTML title for the term.", + "type": "string", + "required": true + }, + "slug": { + "description": "An alphanumeric identifier for the term unique to its type.", + "type": "string", + "required": false + }, + "parent": { + "description": "The parent term ID.", + "type": "integer", + "required": false + }, + "meta": { + "description": "Meta fields.", + "type": "object", + "properties": [], + "required": false + } + } + } + ], + "_links": { + "self": "http://example.org/index.php?rest_route=/wp/v2/categories" } }, - "/wp/v2/taxonomies/(?P[\\w-]+)": { + "/wp/v2/categories/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ - "GET" + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" ], "endpoints": [ { "methods": [ "GET" ], + "allow_batch": { + "v1": true + }, "args": { - "taxonomy": { - "description": "An alphanumeric identifier for the taxonomy.", - "type": "string", + "id": { + "description": "Unique identifier for the term.", + "type": "integer", "required": false }, "context": { @@ -8653,10 +9717,74 @@ mockedApiResponse.Schema = { "required": false } } + }, + { + "methods": [ + "POST", + "PUT", + "PATCH" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the term.", + "type": "integer", + "required": false + }, + "description": { + "description": "HTML description of the term.", + "type": "string", + "required": false + }, + "name": { + "description": "HTML title for the term.", + "type": "string", + "required": false + }, + "slug": { + "description": "An alphanumeric identifier for the term unique to its type.", + "type": "string", + "required": false + }, + "parent": { + "description": "The parent term ID.", + "type": "integer", + "required": false + }, + "meta": { + "description": "Meta fields.", + "type": "object", + "properties": [], + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the term.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Required to be true, as terms do not support trashing.", + "required": false + } + } } ] }, - "/wp/v2/categories": { + "/wp/v2/tags": { "namespace": "wp/v2", "methods": [ "GET", @@ -8720,6 +9848,11 @@ mockedApiResponse.Schema = { "default": [], "required": false }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, "order": { "description": "Order sort attribute ascending or descending.", "type": "string", @@ -8752,11 +9885,6 @@ mockedApiResponse.Schema = { "default": false, "required": false }, - "parent": { - "description": "Limit result set to terms assigned to a specific parent.", - "type": "integer", - "required": false - }, "post": { "description": "Limit result set to terms assigned to a specific post.", "type": "integer", @@ -8796,11 +9924,6 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, - "parent": { - "description": "The parent term ID.", - "type": "integer", - "required": false - }, "meta": { "description": "Meta fields.", "type": "object", @@ -8811,10 +9934,10 @@ mockedApiResponse.Schema = { } ], "_links": { - "self": "http://example.org/index.php?rest_route=/wp/v2/categories" + "self": "http://example.org/index.php?rest_route=/wp/v2/tags" } }, - "/wp/v2/categories/(?P[\\d]+)": { + "/wp/v2/tags/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -8880,11 +10003,6 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, - "parent": { - "description": "The parent term ID.", - "type": "integer", - "required": false - }, "meta": { "description": "Meta fields.", "type": "object", @@ -8916,7 +10034,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/tags": { + "/wp/v2/menus": { "namespace": "wp/v2", "methods": [ "GET", @@ -9061,15 +10179,32 @@ mockedApiResponse.Schema = { "type": "object", "properties": [], "required": false + }, + "locations": { + "description": "The locations assigned to the menu.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "auto_add": { + "description": "Whether to automatically add top level pages to this menu.", + "type": "boolean", + "required": false } } } ], "_links": { - "self": "http://example.org/index.php?rest_route=/wp/v2/tags" + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/menus" + } + ] } }, - "/wp/v2/tags/(?P[\\d]+)": { + "/wp/v2/menus/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -9140,6 +10275,19 @@ mockedApiResponse.Schema = { "type": "object", "properties": [], "required": false + }, + "locations": { + "description": "The locations assigned to the menu.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "auto_add": { + "description": "Whether to automatically add top level pages to this menu.", + "type": "boolean", + "required": false } } }, @@ -9166,7 +10314,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/menus": { + "/wp/v2/wp_pattern_category": { "namespace": "wp/v2", "methods": [ "GET", @@ -9311,19 +10459,6 @@ mockedApiResponse.Schema = { "type": "object", "properties": [], "required": false - }, - "locations": { - "description": "The locations assigned to the menu.", - "type": "array", - "items": { - "type": "string" - }, - "required": false - }, - "auto_add": { - "description": "Whether to automatically add top level pages to this menu.", - "type": "boolean", - "required": false } } } @@ -9331,12 +10466,12 @@ mockedApiResponse.Schema = { "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/menus" + "href": "http://example.org/index.php?rest_route=/wp/v2/wp_pattern_category" } ] } }, - "/wp/v2/menus/(?P[\\d]+)": { + "/wp/v2/wp_pattern_category/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -9407,19 +10542,6 @@ mockedApiResponse.Schema = { "type": "object", "properties": [], "required": false - }, - "locations": { - "description": "The locations assigned to the menu.", - "type": "array", - "items": { - "type": "string" - }, - "required": false - }, - "auto_add": { - "description": "Whether to automatically add top level pages to this menu.", - "type": "boolean", - "required": false } } }, @@ -9446,7 +10568,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/wp_pattern_category": { + "/wp/v2/wp_knowledge_type": { "namespace": "wp/v2", "methods": [ "GET", @@ -9510,11 +10632,6 @@ mockedApiResponse.Schema = { "default": [], "required": false }, - "offset": { - "description": "Offset the result set by a specific number of items.", - "type": "integer", - "required": false - }, "order": { "description": "Order sort attribute ascending or descending.", "type": "string", @@ -9547,6 +10664,11 @@ mockedApiResponse.Schema = { "default": false, "required": false }, + "parent": { + "description": "Limit result set to terms assigned to a specific parent.", + "type": "integer", + "required": false + }, "post": { "description": "Limit result set to terms assigned to a specific post.", "type": "integer", @@ -9586,6 +10708,11 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, + "parent": { + "description": "The parent term ID.", + "type": "integer", + "required": false + }, "meta": { "description": "Meta fields.", "type": "object", @@ -9598,12 +10725,12 @@ mockedApiResponse.Schema = { "_links": { "self": [ { - "href": "http://example.org/index.php?rest_route=/wp/v2/wp_pattern_category" + "href": "http://example.org/index.php?rest_route=/wp/v2/wp_knowledge_type" } ] } }, - "/wp/v2/wp_pattern_category/(?P[\\d]+)": { + "/wp/v2/wp_knowledge_type/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -9669,6 +10796,11 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, + "parent": { + "description": "The parent term ID.", + "type": "integer", + "required": false + }, "meta": { "description": "Meta fields.", "type": "object", @@ -9846,7 +10978,8 @@ mockedApiResponse.Schema = { "wp_global_styles": "wp_global_styles", "wp_navigation": "wp_navigation", "wp_font_family": "wp_font_family", - "wp_font_face": "wp_font_face" + "wp_font_face": "wp_font_face", + "wp_knowledge": "wp_knowledge" } }, "required": false @@ -14174,6 +15307,40 @@ mockedApiResponse.TypesCollection = { } ] } + }, + "wp_knowledge": { + "description": "", + "hierarchical": false, + "has_archive": false, + "name": "Knowledge", + "slug": "wp_knowledge", + "icon": null, + "taxonomies": [ + "wp_knowledge_type" + ], + "rest_base": "knowledge", + "rest_namespace": "wp/v2", + "template": [], + "template_lock": false, + "_links": { + "collection": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/types" + } + ], + "wp:items": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/knowledge" + } + ], + "curies": [ + { + "name": "wp", + "href": "https://api.w.org/{rel}", + "templated": true + } + ] + } } }; @@ -14409,6 +15576,36 @@ mockedApiResponse.TaxonomiesCollection = { } ] } + }, + "wp_knowledge_type": { + "name": "Knowledge Types", + "slug": "wp_knowledge_type", + "description": "", + "types": [ + "wp_knowledge" + ], + "hierarchical": true, + "rest_base": "wp_knowledge_type", + "rest_namespace": "wp/v2", + "_links": { + "collection": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/taxonomies" + } + ], + "wp:items": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/wp_knowledge_type" + } + ], + "curies": [ + { + "name": "wp", + "href": "https://api.w.org/{rel}", + "templated": true + } + ] + } } }; From 84b21a47f099cd993476f907d74a7c59796bb98c Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 18 Jun 2026 09:36:38 +0200 Subject: [PATCH 02/20] Knowledge: Address PR feedback on naming and capability filter. Drop the underscore prefix from the private wp_knowledge_ensure_default_type_term() and wp_knowledge_maybe_map_term_label() functions, following the coding standards change that annotates private functions with @access private instead of a name prefix. Update the corresponding hooks and @covers tags. Remove the scalar/array type hints from wp_maybe_grant_knowledge_caps() so it matches the graceful coercion used by the sibling user_has_cap callbacks (wp_maybe_grant_site_health_caps() et al.). Assert in the controller test that the revisions routes are registered while the autosaves routes are not, documenting the disabled autosave support. Follow-up to [d008aaa614]. Trac ticket: https://core.trac.wordpress.org/ticket/65476 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/capabilities.php | 2 +- src/wp-includes/default-filters.php | 4 ++-- src/wp-includes/knowledge.php | 4 ++-- tests/phpunit/tests/knowledge/types.php | 10 +++++----- .../tests/rest-api/wpRestKnowledgeController.php | 8 ++++++++ 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index d4f4991110b15..f8c71cb427b2b 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1390,7 +1390,7 @@ function wp_maybe_grant_site_health_caps( $allcaps, $caps, $args, $user ) { * @param WP_User $user The user object. * @return bool[] Filtered array of the user's capabilities. */ -function wp_maybe_grant_knowledge_caps( array $allcaps, array $caps, array $args, WP_User $user ): array { +function wp_maybe_grant_knowledge_caps( $allcaps, $caps, $args, $user ) { if ( ! empty( $allcaps['manage_options'] ) ) { $allcaps['read_knowledge'] = true; $allcaps['edit_knowledge'] = true; diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 43329fc47f102..4d69eaf1debf3 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -790,8 +790,8 @@ add_filter( 'rest_wp_navigation_item_schema', array( 'WP_Navigation_Fallback', 'update_wp_navigation_post_schema' ) ); // wp_knowledge post type. -add_action( 'save_post_wp_knowledge', '_wp_knowledge_ensure_default_type_term' ); -add_filter( 'wp_insert_term_data', '_wp_knowledge_maybe_map_term_label', 10, 2 ); +add_action( 'save_post_wp_knowledge', 'wp_knowledge_ensure_default_type_term' ); +add_filter( 'wp_insert_term_data', 'wp_knowledge_maybe_map_term_label', 10, 2 ); // Fluid typography. add_filter( 'render_block', 'wp_render_typography_support', 10, 2 ); diff --git a/src/wp-includes/knowledge.php b/src/wp-includes/knowledge.php index 9956bfddce88b..e646ec55a4901 100644 --- a/src/wp-includes/knowledge.php +++ b/src/wp-includes/knowledge.php @@ -76,7 +76,7 @@ function wp_knowledge_types(): array { * * @param int $post_id Saved post ID. */ -function _wp_knowledge_ensure_default_type_term( int $post_id ): void { +function wp_knowledge_ensure_default_type_term( int $post_id ): void { if ( wp_is_post_revision( $post_id ) ) { return; } @@ -124,7 +124,7 @@ function _wp_knowledge_ensure_default_type_term( int $post_id ): void { * @phpstan-param array $data * @phpstan-return array */ -function _wp_knowledge_maybe_map_term_label( array $data, string $taxonomy ): array { +function wp_knowledge_maybe_map_term_label( array $data, string $taxonomy ): array { if ( 'wp_knowledge_type' !== $taxonomy ) { return $data; } diff --git a/tests/phpunit/tests/knowledge/types.php b/tests/phpunit/tests/knowledge/types.php index ff9921d8071e1..f2b26b2272dd6 100644 --- a/tests/phpunit/tests/knowledge/types.php +++ b/tests/phpunit/tests/knowledge/types.php @@ -47,7 +47,7 @@ public function test_types_are_filterable() { * A knowledge row saved without a type term should fall back to `note`. * * @ticket 65476 - * @covers ::_wp_knowledge_ensure_default_type_term + * @covers ::wp_knowledge_ensure_default_type_term */ public function test_default_type_term_is_assigned_on_save() { $post_id = self::factory()->post->create( @@ -66,7 +66,7 @@ public function test_default_type_term_is_assigned_on_save() { * A row that already carries a type term should keep it, not gain `note`. * * @ticket 65476 - * @covers ::_wp_knowledge_ensure_default_type_term + * @covers ::wp_knowledge_ensure_default_type_term */ public function test_existing_type_term_is_preserved_on_save() { $post_id = self::factory()->post->create( @@ -97,7 +97,7 @@ public function test_existing_type_term_is_preserved_on_save() { * A term lazily created from a registered slug gets the registered label. * * @ticket 65476 - * @covers ::_wp_knowledge_maybe_map_term_label + * @covers ::wp_knowledge_maybe_map_term_label */ public function test_registered_slug_term_gets_mapped_label() { $term = wp_insert_term( 'guideline', 'wp_knowledge_type' ); @@ -113,7 +113,7 @@ public function test_registered_slug_term_gets_mapped_label() { * A user-provided label (where name differs from the slug) is left intact. * * @ticket 65476 - * @covers ::_wp_knowledge_maybe_map_term_label + * @covers ::wp_knowledge_maybe_map_term_label */ public function test_custom_label_is_not_overwritten() { $term = wp_insert_term( 'My Custom Type', 'wp_knowledge_type', array( 'slug' => 'guideline' ) ); @@ -129,7 +129,7 @@ public function test_custom_label_is_not_overwritten() { * The label mapping must not touch terms in other taxonomies. * * @ticket 65476 - * @covers ::_wp_knowledge_maybe_map_term_label + * @covers ::wp_knowledge_maybe_map_term_label */ public function test_label_mapping_is_scoped_to_knowledge_taxonomy() { $term = wp_insert_term( 'guideline', 'category' ); diff --git a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php index a4515f5ed4540..66099f3ea72ad 100644 --- a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php +++ b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php @@ -81,6 +81,14 @@ public function test_register_routes() { $this->assertArrayHasKey( '/wp/v2/knowledge', $routes ); $this->assertArrayHasKey( '/wp/v2/knowledge/(?P[\d]+)', $routes ); + + // Revisions are supported. + $this->assertArrayHasKey( '/wp/v2/knowledge/(?P[\d]+)/revisions', $routes ); + $this->assertArrayHasKey( '/wp/v2/knowledge/(?P[\d]+)/revisions/(?P[\d]+)', $routes ); + + // Autosave support is removed, so the autosaves routes are not registered. + $this->assertArrayNotHasKey( '/wp/v2/knowledge/(?P[\d]+)/autosaves', $routes ); + $this->assertArrayNotHasKey( '/wp/v2/knowledge/(?P[\d]+)/autosaves/(?P[\d]+)', $routes ); } /** From 1a05ea2b3182da59027cd051889d2e591645388c Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 18 Jun 2026 09:50:33 +0200 Subject: [PATCH 03/20] Knowledge: Use plural/singular capability bases per convention. Switch the wp_knowledge capability_type from array( 'knowledge_item', 'knowledge' ) to array( 'knowledge_item', 'knowledge_items' ) so the primitive capabilities follow the standard plural form (edit_knowledge_items) while the per-post meta capabilities keep the singular form (edit_knowledge_item), the same primitive/meta split WordPress uses for posts (edit_posts vs edit_post). Rename the synthesized primitives in wp_maybe_grant_knowledge_caps(), the read remap, and the wp_knowledge_type taxonomy term capabilities that borrow them. Follow-up to [d008aaa614]. Trac ticket: https://core.trac.wordpress.org/ticket/65476 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/capabilities.php | 42 +++++++++---------- src/wp-includes/post.php | 18 +++----- src/wp-includes/taxonomy.php | 10 ++++- .../phpunit/tests/knowledge/capabilities.php | 32 +++++++------- tests/phpunit/tests/knowledge/postType.php | 20 ++++----- 5 files changed, 60 insertions(+), 62 deletions(-) diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index f8c71cb427b2b..689f37c7f5890 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1372,9 +1372,9 @@ function wp_maybe_grant_site_health_caps( $allcaps, $caps, $args, $user ) { * granted dynamically rather than stored on roles. Administrators (users with * `manage_options`) receive every knowledge capability. Contributors, authors, * and editors (users with `edit_posts`) may list and create knowledge rows and - * fully manage their own private rows; publishing knowledge and acting on other + * fully manage their own private rows. Publishing knowledge and acting on other * users' rows is reserved for administrators. Subscribers receive nothing and - * are stopped at the post-type door by the `read_knowledge` mapping. + * are stopped at the post-type door by the `read_knowledge_items` mapping. * * @since 7.1.0 * @@ -1392,17 +1392,17 @@ function wp_maybe_grant_site_health_caps( $allcaps, $caps, $args, $user ) { */ function wp_maybe_grant_knowledge_caps( $allcaps, $caps, $args, $user ) { if ( ! empty( $allcaps['manage_options'] ) ) { - $allcaps['read_knowledge'] = true; - $allcaps['edit_knowledge'] = true; - $allcaps['edit_others_knowledge'] = true; - $allcaps['edit_published_knowledge'] = true; - $allcaps['edit_private_knowledge'] = true; - $allcaps['publish_knowledge'] = true; - $allcaps['delete_knowledge'] = true; - $allcaps['delete_others_knowledge'] = true; - $allcaps['delete_published_knowledge'] = true; - $allcaps['delete_private_knowledge'] = true; - $allcaps['read_private_knowledge'] = true; + $allcaps['read_knowledge_items'] = true; + $allcaps['edit_knowledge_items'] = true; + $allcaps['edit_others_knowledge_items'] = true; + $allcaps['edit_published_knowledge_items'] = true; + $allcaps['edit_private_knowledge_items'] = true; + $allcaps['publish_knowledge_items'] = true; + $allcaps['delete_knowledge_items'] = true; + $allcaps['delete_others_knowledge_items'] = true; + $allcaps['delete_published_knowledge_items'] = true; + $allcaps['delete_private_knowledge_items'] = true; + $allcaps['read_private_knowledge_items'] = true; return $allcaps; } @@ -1412,13 +1412,13 @@ function wp_maybe_grant_knowledge_caps( $allcaps, $caps, $args, $user ) { } /* - * Ambient floor for contributors and above: `read_knowledge` clears the - * post-type read check; `edit_knowledge` clears the create and ownership + * Ambient floor for contributors and above: `read_knowledge_items` clears the + * post-type read check; `edit_knowledge_items` clears the create and ownership * checks that do not pass a post ID. Per-post primitives are granted only * in the per-post branch below. */ - $allcaps['read_knowledge'] = true; - $allcaps['edit_knowledge'] = true; + $allcaps['read_knowledge_items'] = true; + $allcaps['edit_knowledge_items'] = true; if ( ! isset( $args[0], $args[2] ) ) { return $allcaps; @@ -1438,10 +1438,10 @@ function wp_maybe_grant_knowledge_caps( $allcaps, $caps, $args, $user ) { return $allcaps; } - $allcaps['edit_private_knowledge'] = true; - $allcaps['delete_knowledge'] = true; - $allcaps['delete_private_knowledge'] = true; - $allcaps['read_private_knowledge'] = true; + $allcaps['edit_private_knowledge_items'] = true; + $allcaps['delete_knowledge_items'] = true; + $allcaps['delete_private_knowledge_items'] = true; + $allcaps['read_private_knowledge_items'] = true; return $allcaps; } diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index ac337b415ed82..438f127f9bbf6 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -669,28 +669,20 @@ function create_initial_post_types() { '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ 'hierarchical' => false, /* - * Knowledge rows have no native post-type screens; they are managed + * Knowledge rows have no native post-type screens. They are managed * through the REST API and consuming features, not the wp-admin UI. */ 'show_ui' => false, 'map_meta_cap' => true, - /* - * "Knowledge" is a mass noun, so the singular and plural capability - * bases must differ: with both set to `knowledge`, the generated - * per-post meta caps (`edit_knowledge_item`) would collide with the - * primitive caps (`edit_knowledge`). The `*_knowledge_item` forms are - * never granted directly; `map_meta_cap()` resolves them onto the - * primitives, which `wp_maybe_grant_knowledge_caps()` synthesizes. - */ - 'capability_type' => array( 'knowledge_item', 'knowledge' ), + 'capability_type' => array( 'knowledge_item', 'knowledge_items' ), /* * `read` is remapped so that subscribers (who hold the base `read` * capability) are stopped at the post-type door. Every other - * primitive defaults to a `knowledge`-prefixed capability granted by - * `wp_maybe_grant_knowledge_caps()`. + * primitive defaults to a `knowledge_items`-suffixed capability granted + * by `wp_maybe_grant_knowledge_caps()`. */ 'capabilities' => array( - 'read' => 'read_knowledge', + 'read' => 'read_knowledge_items', ), 'query_var' => false, 'rewrite' => false, diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 75fd6b5e02af1..726d47d72b91d 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -274,11 +274,17 @@ function create_initial_taxonomies() { 'name' => _x( 'Knowledge Types', 'taxonomy general name' ), 'singular_name' => _x( 'Knowledge Type', 'taxonomy singular name' ), ), + /* + * Editing and assigning terms reuse the `wp_knowledge` primitive + * `edit_knowledge_items` so that anyone who can edit a knowledge row + * can also lazily create and assign its type. Managing or deleting the + * type vocabulary itself stays an administrator task. + */ 'capabilities' => array( 'manage_terms' => 'manage_options', - 'edit_terms' => 'edit_knowledge', + 'edit_terms' => 'edit_knowledge_items', 'delete_terms' => 'manage_options', - 'assign_terms' => 'edit_knowledge', + 'assign_terms' => 'edit_knowledge_items', ), 'query_var' => false, 'rewrite' => false, diff --git a/tests/phpunit/tests/knowledge/capabilities.php b/tests/phpunit/tests/knowledge/capabilities.php index 40f58827f4b96..ad214bc971d84 100644 --- a/tests/phpunit/tests/knowledge/capabilities.php +++ b/tests/phpunit/tests/knowledge/capabilities.php @@ -76,13 +76,13 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { public function test_administrator_has_every_primitive() { wp_set_current_user( self::$users['administrator'] ); - $this->assertTrue( current_user_can( 'read_knowledge' ) ); - $this->assertTrue( current_user_can( 'edit_knowledge' ) ); - $this->assertTrue( current_user_can( 'edit_others_knowledge' ) ); - $this->assertTrue( current_user_can( 'publish_knowledge' ) ); - $this->assertTrue( current_user_can( 'delete_knowledge' ) ); - $this->assertTrue( current_user_can( 'delete_others_knowledge' ) ); - $this->assertTrue( current_user_can( 'read_private_knowledge' ) ); + $this->assertTrue( current_user_can( 'read_knowledge_items' ) ); + $this->assertTrue( current_user_can( 'edit_knowledge_items' ) ); + $this->assertTrue( current_user_can( 'edit_others_knowledge_items' ) ); + $this->assertTrue( current_user_can( 'publish_knowledge_items' ) ); + $this->assertTrue( current_user_can( 'delete_knowledge_items' ) ); + $this->assertTrue( current_user_can( 'delete_others_knowledge_items' ) ); + $this->assertTrue( current_user_can( 'read_private_knowledge_items' ) ); } /** @@ -102,8 +102,8 @@ public function test_administrator_can_act_on_others_rows() { public function test_subscriber_has_no_access() { wp_set_current_user( self::$users['subscriber'] ); - $this->assertFalse( current_user_can( 'read_knowledge' ) ); - $this->assertFalse( current_user_can( 'edit_knowledge' ) ); + $this->assertFalse( current_user_can( 'read_knowledge_items' ) ); + $this->assertFalse( current_user_can( 'edit_knowledge_items' ) ); } /** @@ -112,8 +112,8 @@ public function test_subscriber_has_no_access() { public function test_anonymous_has_no_access() { wp_set_current_user( 0 ); - $this->assertFalse( current_user_can( 'read_knowledge' ) ); - $this->assertFalse( current_user_can( 'edit_knowledge' ) ); + $this->assertFalse( current_user_can( 'read_knowledge_items' ) ); + $this->assertFalse( current_user_can( 'edit_knowledge_items' ) ); } /** @@ -127,13 +127,13 @@ public function test_contributor_level_ambient_floor( $role ) { wp_set_current_user( self::$users[ $role ] ); // May list and create knowledge. - $this->assertTrue( current_user_can( 'read_knowledge' ), "$role should read_knowledge" ); - $this->assertTrue( current_user_can( 'edit_knowledge' ), "$role should edit_knowledge" ); + $this->assertTrue( current_user_can( 'read_knowledge_items' ), "$role should read_knowledge_items" ); + $this->assertTrue( current_user_can( 'edit_knowledge_items' ), "$role should edit_knowledge_items" ); // May not publish or act on other users' rows. - $this->assertFalse( current_user_can( 'publish_knowledge' ), "$role should not publish_knowledge" ); - $this->assertFalse( current_user_can( 'edit_others_knowledge' ), "$role should not edit_others_knowledge" ); - $this->assertFalse( current_user_can( 'delete_others_knowledge' ), "$role should not delete_others_knowledge" ); + $this->assertFalse( current_user_can( 'publish_knowledge_items' ), "$role should not publish_knowledge_items" ); + $this->assertFalse( current_user_can( 'edit_others_knowledge_items' ), "$role should not edit_others_knowledge_items" ); + $this->assertFalse( current_user_can( 'delete_others_knowledge_items' ), "$role should not delete_others_knowledge_items" ); } public function data_contributor_level_roles(): array { diff --git a/tests/phpunit/tests/knowledge/postType.php b/tests/phpunit/tests/knowledge/postType.php index 8bf82c76284b5..64b71dcf222a9 100644 --- a/tests/phpunit/tests/knowledge/postType.php +++ b/tests/phpunit/tests/knowledge/postType.php @@ -93,14 +93,14 @@ public function test_post_type_does_not_support_autosaves() { public function test_read_capability_is_remapped() { $post_type = get_post_type_object( 'wp_knowledge' ); - $this->assertSame( 'read_knowledge', $post_type->cap->read ); + $this->assertSame( 'read_knowledge_items', $post_type->cap->read ); } /** - * "Knowledge" is a mass noun, so the per-post meta capabilities (derived - * from the singular base) must not collide with the primitive capabilities - * (derived from the plural base). A collision would make checks such as - * `current_user_can( 'edit_knowledge' )` ambiguous. + * The per-post meta capabilities (derived from the singular `knowledge_item` + * base) must not collide with the primitive capabilities (derived from the + * plural `knowledge_items` base). A collision would make checks such as + * `current_user_can( 'edit_knowledge_items' )` ambiguous. * * @ticket 65476 * @covers ::create_initial_post_types @@ -113,11 +113,11 @@ public function test_post_type_meta_caps_do_not_collide_with_primitives() { $this->assertSame( 'read_knowledge_item', $cap->read_post ); $this->assertSame( 'delete_knowledge_item', $cap->delete_post ); - // Primitive capabilities are derived from the plural `knowledge` base. - $this->assertSame( 'edit_knowledge', $cap->edit_posts ); - $this->assertSame( 'edit_others_knowledge', $cap->edit_others_posts ); - $this->assertSame( 'publish_knowledge', $cap->publish_posts ); - $this->assertSame( 'read_private_knowledge', $cap->read_private_posts ); + // Primitive capabilities are derived from the plural `knowledge_items` base. + $this->assertSame( 'edit_knowledge_items', $cap->edit_posts ); + $this->assertSame( 'edit_others_knowledge_items', $cap->edit_others_posts ); + $this->assertSame( 'publish_knowledge_items', $cap->publish_posts ); + $this->assertSame( 'read_private_knowledge_items', $cap->read_private_posts ); // The meta and primitive forms must be distinct. $this->assertNotSame( $cap->edit_post, $cap->edit_posts ); From e7d4f1c2a94eaab71d4016a74dfe4017d68591e1 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 18 Jun 2026 10:16:19 +0200 Subject: [PATCH 04/20] Knowledge: Strengthen REST controller permission test coverage. Assert a fixed 403 (rather than 401 or 403) when an authenticated contributor reads another user's private row, and add coverage proving a contributor can update and delete their own private row over REST while being forbidden from updating or deleting another user's row. Follow-up to [d008aaa614]. Trac ticket: https://core.trac.wordpress.org/ticket/65476 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rest-api/wpRestKnowledgeController.php | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php index 66099f3ea72ad..34046e8e10561 100644 --- a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php +++ b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php @@ -165,7 +165,8 @@ public function test_contributor_cannot_read_others_private_row() { $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge/' . self::$admin_private ); $response = rest_get_server()->dispatch( $request ); - $this->assertContains( $response->get_status(), array( 401, 403 ) ); + // The contributor is authenticated, so reading another user's private row is forbidden. + $this->assertSame( 403, $response->get_status() ); } /** @@ -232,6 +233,39 @@ public function test_update_item() { $this->assertSame( 'Updated title', $response->get_data()['title']['raw'] ); } + /** + * A contributor may edit their own private row. + * + * @ticket 65476 + */ + public function test_contributor_can_update_own_row() { + wp_set_current_user( self::$contributor_id ); + + $post_id = $this->create_knowledge_post( self::$contributor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge/' . $post_id ); + $request->set_body_params( array( 'title' => 'Updated by contributor' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'Updated by contributor', $response->get_data()['title']['raw'] ); + } + + /** + * A contributor may not edit another user's row. + * + * @ticket 65476 + */ + public function test_contributor_cannot_update_others_row() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge/' . self::$admin_private ); + $request->set_body_params( array( 'title' => 'Attempted update' ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } + /** * @ticket 65476 */ @@ -248,6 +282,40 @@ public function test_delete_item() { $this->assertNull( get_post( $post_id ) ); } + /** + * A contributor may delete their own private row. + * + * @ticket 65476 + */ + public function test_contributor_can_delete_own_row() { + wp_set_current_user( self::$contributor_id ); + + $post_id = $this->create_knowledge_post( self::$contributor_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/knowledge/' . $post_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( get_post( $post_id ) ); + } + + /** + * A contributor may not delete another user's row. + * + * @ticket 65476 + */ + public function test_contributor_cannot_delete_others_row() { + wp_set_current_user( self::$contributor_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/knowledge/' . self::$admin_private ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + $this->assertInstanceOf( WP_Post::class, get_post( self::$admin_private ) ); + } + /** * @ticket 65476 */ From 5c459e8038e4939f1d9ac2e015554c65df888080 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 18 Jun 2026 10:30:37 +0200 Subject: [PATCH 05/20] Knowledge: Keep author control of their own trashed rows. The per-post capability grant in wp_maybe_grant_knowledge_caps() gated on the current post status being private, so trashing a private row (which flips the status to trash) stripped the author's delete_knowledge_items grant and left them unable to permanently delete or restore their own row. Resolve the effective status before the check by falling back to the pre-trash status stored in _wp_trash_meta_status, so a row trashed from private keeps the grant while a row trashed from another status stays outside it. Add a regression test covering a contributor deleting their own trashed row. Follow-up to [d008aaa614]. Trac ticket: https://core.trac.wordpress.org/ticket/65476 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/capabilities.php | 19 +++++++++++-- .../phpunit/tests/knowledge/capabilities.php | 27 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 689f37c7f5890..326fffc567525 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1432,12 +1432,27 @@ function wp_maybe_grant_knowledge_caps( $allcaps, $caps, $args, $user ) { if ( ! $post instanceof WP_Post || 'wp_knowledge' !== $post->post_type || - (int) $post->post_author !== (int) $user->ID || - 'private' !== $post->post_status + (int) $post->post_author !== (int) $user->ID ) { return $allcaps; } + /* + * A trashed row keeps its pre-trash status in `_wp_trash_meta_status`. + * Resolve that effective status so the author keeps the ability to restore + * or permanently delete their own row once it is in the trash. A row trashed + * from a non-private status (only reachable for administrators) still falls + * outside the grant. + */ + $status = $post->post_status; + if ( 'trash' === $status ) { + $status = get_post_meta( $post->ID, '_wp_trash_meta_status', true ); + } + + if ( 'private' !== $status ) { + return $allcaps; + } + $allcaps['edit_private_knowledge_items'] = true; $allcaps['delete_knowledge_items'] = true; $allcaps['delete_private_knowledge_items'] = true; diff --git a/tests/phpunit/tests/knowledge/capabilities.php b/tests/phpunit/tests/knowledge/capabilities.php index ad214bc971d84..2dbe7af7ecdb8 100644 --- a/tests/phpunit/tests/knowledge/capabilities.php +++ b/tests/phpunit/tests/knowledge/capabilities.php @@ -155,6 +155,33 @@ public function test_contributor_can_manage_own_private_row() { $this->assertTrue( current_user_can( 'delete_post', self::$own_private ) ); } + /** + * A contributor keeps control of their own row after trashing it. + * + * Trashing flips the status to `trash`, but the pre-trash `private` status is + * preserved in `_wp_trash_meta_status`, so the per-post grant must still apply + * and let the author permanently delete (or restore) their own trashed row. + * + * @ticket 65476 + */ + public function test_contributor_can_delete_own_trashed_row() { + wp_set_current_user( self::$users['contributor'] ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$users['contributor'], + ) + ); + + wp_trash_post( $post_id ); + $this->assertSame( 'trash', get_post_status( $post_id ) ); + + $this->assertTrue( current_user_can( 'delete_post', $post_id ) ); + $this->assertTrue( current_user_can( 'edit_post', $post_id ) ); + } + /** * @ticket 65476 */ From a65e9d8f1b03b60a3b833e5fd1e8be8ca382f602 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 18 Jun 2026 10:35:36 +0200 Subject: [PATCH 06/20] Knowledge: Align the collection read error code with core controllers. Return rest_cannot_read instead of rest_forbidden when an unauthorized user requests the knowledge collection, matching the font families, global styles, and comments controllers. Tighten the authentication test to assert the error code rather than only the HTTP status, which both codes share. Follow-up to [d008aaa614]. Trac ticket: https://core.trac.wordpress.org/ticket/65476 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rest-api/endpoints/class-wp-rest-knowledge-controller.php | 2 +- tests/phpunit/tests/rest-api/wpRestKnowledgeController.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php index 14e3b4bd64def..8f4e2d435e34f 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php @@ -36,7 +36,7 @@ public function get_items_permissions_check( $request ) { $post_type = get_post_type_object( $this->post_type ); if ( ! current_user_can( $post_type->cap->read ) ) { return new WP_Error( - 'rest_forbidden', + 'rest_cannot_read', __( 'Sorry, you are not allowed to view knowledge.' ), array( 'status' => rest_authorization_required_code() ) ); diff --git a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php index 34046e8e10561..0069c503bfba6 100644 --- a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php +++ b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php @@ -140,7 +140,7 @@ public function test_get_items_requires_authentication() { wp_set_current_user( self::$subscriber_id ); $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 403, $response->get_status() ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); } /** From a65c11c74320c8309c0c81696ef38a76c829a723 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 18 Jun 2026 10:38:51 +0200 Subject: [PATCH 07/20] Knowledge: Drop the no-op show_admin_column from wp_knowledge_type. show_admin_column only adds a taxonomy column to a post type's edit screen list table, which requires the post type to have show_ui enabled. wp_knowledge sets show_ui to false, so the flag rendered nothing and only surfaced a misleading true in the /wp/v2/taxonomies response. Let it default to false, matching the taxonomy's other admin-facing flags. Follow-up to [d008aaa614]. Trac ticket: https://core.trac.wordpress.org/ticket/65476 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/taxonomy.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 726d47d72b91d..bb085cf7585ef 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -292,7 +292,6 @@ function create_initial_taxonomies() { '_builtin' => true, 'show_in_nav_menus' => false, 'show_in_rest' => true, - 'show_admin_column' => true, ) ); } From a15583271220f00a79bf4847c66a18dd74714f8a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 18:19:37 -0700 Subject: [PATCH 08/20] Fix type issues with wp_maybe_grant_knowledge_caps() --- src/wp-includes/capabilities.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 326fffc567525..7e8f113f3fa7b 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1378,9 +1378,9 @@ function wp_maybe_grant_site_health_caps( $allcaps, $caps, $args, $user ) { * * @since 7.1.0 * - * @param bool[] $allcaps An array of all the user's capabilities. - * @param string[] $caps Required primitive capabilities for the requested capability. - * @param array $args { + * @param array $allcaps An array of all the user's capabilities. + * @param string[] $caps Required primitive capabilities for the requested capability. + * @param array $args { * Arguments that accompany the requested capability check. * * @type string $0 Requested capability. @@ -1388,7 +1388,8 @@ function wp_maybe_grant_site_health_caps( $allcaps, $caps, $args, $user ) { * @type mixed ...$2 Optional second and further parameters, typically object ID. * } * @param WP_User $user The user object. - * @return bool[] Filtered array of the user's capabilities. + * @return array Filtered array of the user's capabilities. + * @phpstan-param array{ 0: string, 1: positive-int, 2: mixed, ... } $args */ function wp_maybe_grant_knowledge_caps( $allcaps, $caps, $args, $user ) { if ( ! empty( $allcaps['manage_options'] ) ) { @@ -1420,7 +1421,7 @@ function wp_maybe_grant_knowledge_caps( $allcaps, $caps, $args, $user ) { $allcaps['read_knowledge_items'] = true; $allcaps['edit_knowledge_items'] = true; - if ( ! isset( $args[0], $args[2] ) ) { + if ( ! isset( $args[0], $args[2] ) || ! is_int( $args[2] ) ) { return $allcaps; } From 37336307ce4c33b346b12c8af11ef642b978498d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 18:37:12 -0700 Subject: [PATCH 09/20] Fix typing with wp_knowledge_maybe_map_term_label() --- src/wp-includes/knowledge.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/knowledge.php b/src/wp-includes/knowledge.php index e646ec55a4901..9d2bfb8068598 100644 --- a/src/wp-includes/knowledge.php +++ b/src/wp-includes/knowledge.php @@ -121,10 +121,14 @@ function wp_knowledge_ensure_default_type_term( int $post_id ): void { * @param string $taxonomy Taxonomy slug. * @return array Possibly modified term data. * - * @phpstan-param array $data - * @phpstan-return array + * @phpstan-param array{ name: string, slug: string } $data + * @phpstan-return array{ name: string, slug: string } */ -function wp_knowledge_maybe_map_term_label( array $data, string $taxonomy ): array { +function wp_knowledge_maybe_map_term_label( $data, string $taxonomy ): array { + if ( ! is_array( $data ) ) { + $data = array_fill_keys( array( 'name', 'slug' ), '' ); + } + if ( 'wp_knowledge_type' !== $taxonomy ) { return $data; } From 3180f8e05a5eaa3e618b497afb31d943558a27c2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 18:39:30 -0700 Subject: [PATCH 10/20] Fix typing on prepare_items_query() --- .../endpoints/class-wp-rest-knowledge-controller.php | 6 +++--- .../rest-api/endpoints/class-wp-rest-posts-controller.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php index 8f4e2d435e34f..19fa2402a5121 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php @@ -54,9 +54,9 @@ public function get_items_permissions_check( $request ) { * * @since 7.1.0 * - * @param array $prepared_args Optional. Prepared WP_Query arguments. Default empty array. - * @param WP_REST_Request $request Optional. Full details about the request. - * @return array Items query arguments. + * @param array $prepared_args Optional. Prepared WP_Query arguments. Default empty array. + * @param WP_REST_Request|null $request Optional. Full details about the request. + * @return array Items query arguments. */ protected function prepare_items_query( $prepared_args = array(), $request = null ) { $query_args = parent::prepare_items_query( $prepared_args, $request ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php index ee3e6b4959869..955ec4d22197c 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php @@ -1208,9 +1208,9 @@ public function delete_item( $request ) { * * @since 4.7.0 * - * @param array $prepared_args Optional. Prepared WP_Query arguments. Default empty array. - * @param WP_REST_Request $request Optional. Full details about the request. - * @return array Items query arguments. + * @param array $prepared_args Optional. Prepared WP_Query arguments. Default empty array. + * @param WP_REST_Request|null $request Optional. Full details about the request. + * @return array Items query arguments. */ protected function prepare_items_query( $prepared_args = array(), $request = null ) { $query_args = array(); From 2c6665bbd145cf9301cdf400990a6e9090ab1a65 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 18:42:38 -0700 Subject: [PATCH 11/20] Add types to wp_maybe_grant_knowledge_caps() --- src/wp-includes/capabilities.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 7e8f113f3fa7b..e62602ba37d4f 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1391,7 +1391,11 @@ function wp_maybe_grant_site_health_caps( $allcaps, $caps, $args, $user ) { * @return array Filtered array of the user's capabilities. * @phpstan-param array{ 0: string, 1: positive-int, 2: mixed, ... } $args */ -function wp_maybe_grant_knowledge_caps( $allcaps, $caps, $args, $user ) { +function wp_maybe_grant_knowledge_caps( $allcaps, array $caps, array $args, WP_User $user ): array { + if ( ! is_array( $allcaps ) ) { + $allcaps = array(); + } + if ( ! empty( $allcaps['manage_options'] ) ) { $allcaps['read_knowledge_items'] = true; $allcaps['edit_knowledge_items'] = true; From c021f5a1c147a39411713a59546cd853507d82bb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 18:46:47 -0700 Subject: [PATCH 12/20] Guard against post type not being registered in get_items_permissions_check --- .../rest-api/endpoints/class-wp-rest-knowledge-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php index 19fa2402a5121..03f58eebf7b49 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php @@ -34,7 +34,7 @@ class WP_REST_Knowledge_Controller extends WP_REST_Posts_Controller { */ public function get_items_permissions_check( $request ) { $post_type = get_post_type_object( $this->post_type ); - if ( ! current_user_can( $post_type->cap->read ) ) { + if ( ! $post_type || ! current_user_can( $post_type->cap->read ) ) { return new WP_Error( 'rest_cannot_read', __( 'Sorry, you are not allowed to view knowledge.' ), From 97814108386a27cb68bc014b9f9fae040abe9e96 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 30 Jun 2026 16:19:34 +0200 Subject: [PATCH 13/20] Knowledge: Resolve type-term labels in the site locale. wp_knowledge_maybe_map_term_label() is the single point where every wp_knowledge_type term is inserted. Resolve the label there under the site locale so a term name, which is stored once and shared by all users, does not depend on the locale of the request that created it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/knowledge.php | 14 ++++++-- tests/phpunit/tests/knowledge/types.php | 43 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/knowledge.php b/src/wp-includes/knowledge.php index 9d2bfb8068598..9edc682dc796d 100644 --- a/src/wp-includes/knowledge.php +++ b/src/wp-includes/knowledge.php @@ -110,9 +110,10 @@ function wp_knowledge_ensure_default_type_term( int $post_id ): void { * filter runs after WordPress has computed both `name` and `slug`. A `name` * equal to `slug` indicates the term was created from a raw slug (for example by * wp_set_object_terms()) rather than from a user-provided label, so the label is - * replaced with the title from wp_knowledge_types(). Because term names are - * persisted in the database, the translated title is stored in the locale active - * when the term is created. + * replaced with the title from wp_knowledge_types(). + * + * The name is written once and shared by every user, so it is resolved in the + * site locale, not the locale of the request that happens to create the term. * * @since 7.1.0 * @access private @@ -137,10 +138,17 @@ function wp_knowledge_maybe_map_term_label( $data, string $taxonomy ): array { return $data; } + // Type titles may be translatable, so resolve them under the site locale. + $switched_locale = switch_to_locale( get_locale() ); + $types = wp_knowledge_types(); if ( isset( $types[ $data['slug'] ] ) ) { $data['name'] = $types[ $data['slug'] ]['title']; } + if ( $switched_locale ) { + restore_previous_locale(); + } + return $data; } diff --git a/tests/phpunit/tests/knowledge/types.php b/tests/phpunit/tests/knowledge/types.php index f2b26b2272dd6..30e99f72018a7 100644 --- a/tests/phpunit/tests/knowledge/types.php +++ b/tests/phpunit/tests/knowledge/types.php @@ -139,4 +139,47 @@ public function test_label_mapping_is_scoped_to_knowledge_taxonomy() { $this->assertSame( 'guideline', $created->name ); } + + /** + * A term name is stored once and shared, so the label must be resolved in the + * site locale even when the request runs in a different one. + * + * @ticket 65476 + * @covers ::wp_knowledge_maybe_map_term_label + */ + public function test_label_is_resolved_in_site_locale() { + // Simulate a non-site request locale. Priority 1 runs before the locale + // switcher (priority 10), so the mapping's switch still wins. + $request_locale = 'de_DE'; + add_filter( + 'determine_locale', + static function () use ( $request_locale ) { + return $request_locale; + }, + 1 + ); + + // The request runs in a non-site locale before the mapping switches. + $this->assertSame( $request_locale, determine_locale() ); + $this->assertNotSame( + $request_locale, + get_locale(), + 'Test setup requires the site locale to differ from the request locale.' + ); + + $captured = null; + add_filter( + 'wp_knowledge_types', + static function ( $types ) use ( &$captured ) { + $captured = determine_locale(); + return $types; + } + ); + + $term = wp_insert_term( 'note', 'wp_knowledge_type' ); + + $this->assertNotWPError( $term ); + $this->assertSame( get_locale(), $captured, 'Label should be resolved in the site locale.' ); + $this->assertNotSame( $request_locale, $captured, 'Label should not be resolved in the request locale.' ); + } } From a924b44de229c0c6e730f0523b3fcea7cf6ea695 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 9 Jul 2026 11:34:42 -0700 Subject: [PATCH 14/20] Fix param docs indentation --- src/wp-includes/capabilities.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index e62602ba37d4f..6b2e5bbfea02b 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1380,14 +1380,14 @@ function wp_maybe_grant_site_health_caps( $allcaps, $caps, $args, $user ) { * * @param array $allcaps An array of all the user's capabilities. * @param string[] $caps Required primitive capabilities for the requested capability. - * @param array $args { + * @param array $args { * Arguments that accompany the requested capability check. * * @type string $0 Requested capability. * @type int $1 Concerned user ID. * @type mixed ...$2 Optional second and further parameters, typically object ID. * } - * @param WP_User $user The user object. + * @param WP_User $user The user object. * @return array Filtered array of the user's capabilities. * @phpstan-param array{ 0: string, 1: positive-int, 2: mixed, ... } $args */ From 7bcf1e4bef6b38d3dd2973b96a83e61194beca86 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 9 Jul 2026 11:41:37 -0700 Subject: [PATCH 15/20] Improve docblock formatting --- src/wp-includes/knowledge.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/knowledge.php b/src/wp-includes/knowledge.php index 9edc682dc796d..118ed0909c25e 100644 --- a/src/wp-includes/knowledge.php +++ b/src/wp-includes/knowledge.php @@ -105,12 +105,12 @@ function wp_knowledge_ensure_default_type_term( int $post_id ): void { /** * Swaps a raw knowledge-type slug for its registered label on term creation. * - * Hooked to the `wp_insert_term_data` filter. When wp_set_object_terms() is + * Hooked to the `wp_insert_term_data` filter. When `wp_set_object_terms()` is * called with a slug that does not yet exist, wp_insert_term() fires and this * filter runs after WordPress has computed both `name` and `slug`. A `name` * equal to `slug` indicates the term was created from a raw slug (for example by - * wp_set_object_terms()) rather than from a user-provided label, so the label is - * replaced with the title from wp_knowledge_types(). + * `wp_set_object_terms()`) rather than from a user-provided label, so the label is + * replaced with the title from {@see wp_knowledge_types()}. * * The name is written once and shared by every user, so it is resolved in the * site locale, not the locale of the request that happens to create the term. From 52235addd707528a283a0b265aaee557b5560a55 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 9 Jul 2026 12:10:47 -0700 Subject: [PATCH 16/20] Fix most phpstan issues in tests and use native type hints --- .../phpunit/tests/knowledge/capabilities.php | 37 ++++++------- tests/phpunit/tests/knowledge/postType.php | 35 +++++++----- tests/phpunit/tests/knowledge/types.php | 30 ++++++---- .../rest-api/wpRestKnowledgeController.php | 55 ++++++++----------- 4 files changed, 78 insertions(+), 79 deletions(-) diff --git a/tests/phpunit/tests/knowledge/capabilities.php b/tests/phpunit/tests/knowledge/capabilities.php index 2dbe7af7ecdb8..6c4d611a42210 100644 --- a/tests/phpunit/tests/knowledge/capabilities.php +++ b/tests/phpunit/tests/knowledge/capabilities.php @@ -17,30 +17,24 @@ class Tests_Knowledge_Capabilities extends WP_UnitTestCase { * * @var int[] */ - private static $users = array(); + private static array $users = array(); /** * A private knowledge row owned by the contributor. - * - * @var int */ - private static $own_private; + private static int $own_private; /** * A published knowledge row owned by the contributor. - * - * @var int */ - private static $own_published; + private static int $own_published; /** * A private knowledge row owned by the author. - * - * @var int */ - private static $others_private; + private static int $others_private; - public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ): void { foreach ( array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ) as $role ) { self::$users[ $role ] = $factory->user->create( array( 'role' => $role ) ); } @@ -73,7 +67,7 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { /** * @ticket 65476 */ - public function test_administrator_has_every_primitive() { + public function test_administrator_has_every_primitive(): void { wp_set_current_user( self::$users['administrator'] ); $this->assertTrue( current_user_can( 'read_knowledge_items' ) ); @@ -88,7 +82,7 @@ public function test_administrator_has_every_primitive() { /** * @ticket 65476 */ - public function test_administrator_can_act_on_others_rows() { + public function test_administrator_can_act_on_others_rows(): void { wp_set_current_user( self::$users['administrator'] ); $this->assertTrue( current_user_can( 'edit_post', self::$others_private ) ); @@ -99,7 +93,7 @@ public function test_administrator_can_act_on_others_rows() { /** * @ticket 65476 */ - public function test_subscriber_has_no_access() { + public function test_subscriber_has_no_access(): void { wp_set_current_user( self::$users['subscriber'] ); $this->assertFalse( current_user_can( 'read_knowledge_items' ) ); @@ -109,7 +103,7 @@ public function test_subscriber_has_no_access() { /** * @ticket 65476 */ - public function test_anonymous_has_no_access() { + public function test_anonymous_has_no_access(): void { wp_set_current_user( 0 ); $this->assertFalse( current_user_can( 'read_knowledge_items' ) ); @@ -123,7 +117,7 @@ public function test_anonymous_has_no_access() { * * @param string $role Role slug. */ - public function test_contributor_level_ambient_floor( $role ) { + public function test_contributor_level_ambient_floor( $role ): void { wp_set_current_user( self::$users[ $role ] ); // May list and create knowledge. @@ -147,7 +141,7 @@ public function data_contributor_level_roles(): array { /** * @ticket 65476 */ - public function test_contributor_can_manage_own_private_row() { + public function test_contributor_can_manage_own_private_row(): void { wp_set_current_user( self::$users['contributor'] ); $this->assertTrue( current_user_can( 'edit_post', self::$own_private ) ); @@ -164,7 +158,7 @@ public function test_contributor_can_manage_own_private_row() { * * @ticket 65476 */ - public function test_contributor_can_delete_own_trashed_row() { + public function test_contributor_can_delete_own_trashed_row(): void { wp_set_current_user( self::$users['contributor'] ); $post_id = self::factory()->post->create( @@ -174,6 +168,7 @@ public function test_contributor_can_delete_own_trashed_row() { 'post_author' => self::$users['contributor'], ) ); + $this->assertIsInt( $post_id ); wp_trash_post( $post_id ); $this->assertSame( 'trash', get_post_status( $post_id ) ); @@ -185,7 +180,7 @@ public function test_contributor_can_delete_own_trashed_row() { /** * @ticket 65476 */ - public function test_contributor_cannot_edit_own_published_row() { + public function test_contributor_cannot_edit_own_published_row(): void { wp_set_current_user( self::$users['contributor'] ); // Publishing is reserved for administrators, so an already-published @@ -196,7 +191,7 @@ public function test_contributor_cannot_edit_own_published_row() { /** * @ticket 65476 */ - public function test_contributor_cannot_act_on_others_rows() { + public function test_contributor_cannot_act_on_others_rows(): void { wp_set_current_user( self::$users['contributor'] ); $this->assertFalse( current_user_can( 'edit_post', self::$others_private ) ); @@ -207,7 +202,7 @@ public function test_contributor_cannot_act_on_others_rows() { /** * @ticket 65476 */ - public function test_grant_does_not_apply_to_other_post_types() { + public function test_grant_does_not_apply_to_other_post_types(): void { wp_set_current_user( self::$users['contributor'] ); $page_id = self::factory()->post->create( diff --git a/tests/phpunit/tests/knowledge/postType.php b/tests/phpunit/tests/knowledge/postType.php index 64b71dcf222a9..29800ea9f1590 100644 --- a/tests/phpunit/tests/knowledge/postType.php +++ b/tests/phpunit/tests/knowledge/postType.php @@ -14,7 +14,7 @@ class Tests_Knowledge_PostType extends WP_UnitTestCase { * @ticket 65476 * @covers ::create_initial_post_types */ - public function test_post_type_is_registered() { + public function test_post_type_is_registered(): void { $this->assertTrue( post_type_exists( 'wp_knowledge' ) ); } @@ -22,10 +22,10 @@ public function test_post_type_is_registered() { * @ticket 65476 * @covers ::create_initial_post_types */ - public function test_post_type_is_builtin_and_private() { + public function test_post_type_is_builtin_and_private(): void { $post_type = get_post_type_object( 'wp_knowledge' ); - $this->assertInstanceOf( 'WP_Post_Type', $post_type ); + $this->assertInstanceOf( WP_Post_Type::class, $post_type ); $this->assertTrue( $post_type->_builtin, '_builtin should be true' ); $this->assertFalse( $post_type->public, 'public should be false' ); $this->assertFalse( $post_type->show_ui, 'show_ui should be false' ); @@ -36,8 +36,9 @@ public function test_post_type_is_builtin_and_private() { * @ticket 65476 * @covers ::create_initial_post_types */ - public function test_post_type_rest_configuration() { + public function test_post_type_rest_configuration(): void { $post_type = get_post_type_object( 'wp_knowledge' ); + $this->assertInstanceOf( WP_Post_Type::class, $post_type ); $this->assertTrue( $post_type->show_in_rest, 'show_in_rest should be true' ); $this->assertSame( 'knowledge', $post_type->rest_base ); @@ -48,7 +49,7 @@ public function test_post_type_rest_configuration() { * @ticket 65476 * @covers ::create_initial_post_types */ - public function test_post_type_supports() { + public function test_post_type_supports(): void { $this->assertTrue( post_type_supports( 'wp_knowledge', 'title' ) ); $this->assertTrue( post_type_supports( 'wp_knowledge', 'editor' ) ); $this->assertTrue( post_type_supports( 'wp_knowledge', 'excerpt' ) ); @@ -62,10 +63,12 @@ public function test_post_type_supports() { * @ticket 65476 * @covers ::create_initial_post_types */ - public function test_post_type_supports_revisions_with_default_controller() { + public function test_post_type_supports_revisions_with_default_controller(): void { $this->assertTrue( post_type_supports( 'wp_knowledge', 'revisions' ) ); - $controller = get_post_type_object( 'wp_knowledge' )->get_revisions_rest_controller(); + $post_type = get_post_type_object( 'wp_knowledge' ); + $this->assertInstanceOf( WP_Post_Type::class, $post_type ); + $controller = $post_type->get_revisions_rest_controller(); $this->assertInstanceOf( 'WP_REST_Revisions_Controller', $controller ); } @@ -78,9 +81,11 @@ public function test_post_type_supports_revisions_with_default_controller() { * @ticket 65476 * @covers ::create_initial_post_types */ - public function test_post_type_does_not_support_autosaves() { + public function test_post_type_does_not_support_autosaves(): void { $this->assertFalse( post_type_supports( 'wp_knowledge', 'autosave' ) ); - $this->assertNull( get_post_type_object( 'wp_knowledge' )->get_autosave_rest_controller() ); + $post_type = get_post_type_object( 'wp_knowledge' ); + $this->assertInstanceOf( WP_Post_Type::class, $post_type ); + $this->assertNull( $post_type->get_autosave_rest_controller() ); } /** @@ -90,8 +95,9 @@ public function test_post_type_does_not_support_autosaves() { * @ticket 65476 * @covers ::create_initial_post_types */ - public function test_read_capability_is_remapped() { + public function test_read_capability_is_remapped(): void { $post_type = get_post_type_object( 'wp_knowledge' ); + $this->assertInstanceOf( WP_Post_Type::class, $post_type ); $this->assertSame( 'read_knowledge_items', $post_type->cap->read ); } @@ -105,8 +111,10 @@ public function test_read_capability_is_remapped() { * @ticket 65476 * @covers ::create_initial_post_types */ - public function test_post_type_meta_caps_do_not_collide_with_primitives() { - $cap = get_post_type_object( 'wp_knowledge' )->cap; + public function test_post_type_meta_caps_do_not_collide_with_primitives(): void { + $post_type = get_post_type_object( 'wp_knowledge' ); + $this->assertInstanceOf( WP_Post_Type::class, $post_type ); + $cap = $post_type->cap; // Meta capabilities are derived from the singular `knowledge_item` base. $this->assertSame( 'edit_knowledge_item', $cap->edit_post ); @@ -129,11 +137,12 @@ public function test_post_type_meta_caps_do_not_collide_with_primitives() { * @ticket 65476 * @covers ::create_initial_taxonomies */ - public function test_knowledge_type_taxonomy_is_attached() { + public function test_knowledge_type_taxonomy_is_attached(): void { $this->assertTrue( taxonomy_exists( 'wp_knowledge_type' ) ); $this->assertContains( 'wp_knowledge_type', get_object_taxonomies( 'wp_knowledge' ) ); $taxonomy = get_taxonomy( 'wp_knowledge_type' ); + $this->assertInstanceOf( WP_Taxonomy::class, $taxonomy ); $this->assertTrue( $taxonomy->hierarchical, 'taxonomy should be hierarchical' ); $this->assertFalse( $taxonomy->public, 'taxonomy should not be public' ); $this->assertTrue( $taxonomy->show_in_rest, 'taxonomy should be shown in REST' ); diff --git a/tests/phpunit/tests/knowledge/types.php b/tests/phpunit/tests/knowledge/types.php index 30e99f72018a7..ef6f9232547bd 100644 --- a/tests/phpunit/tests/knowledge/types.php +++ b/tests/phpunit/tests/knowledge/types.php @@ -13,7 +13,7 @@ class Tests_Knowledge_Types extends WP_UnitTestCase { * @ticket 65476 * @covers ::wp_knowledge_types */ - public function test_default_types_are_registered() { + public function test_default_types_are_registered(): void { $types = wp_knowledge_types(); $this->assertArrayHasKey( 'guideline', $types ); @@ -29,8 +29,8 @@ public function test_default_types_are_registered() { * @ticket 65476 * @covers ::wp_knowledge_types */ - public function test_types_are_filterable() { - $callback = static function ( $types ) { + public function test_types_are_filterable(): void { + $callback = static function ( array $types ): array { $types['skill'] = array( 'title' => 'Skill' ); return $types; }; @@ -49,13 +49,14 @@ public function test_types_are_filterable() { * @ticket 65476 * @covers ::wp_knowledge_ensure_default_type_term */ - public function test_default_type_term_is_assigned_on_save() { + public function test_default_type_term_is_assigned_on_save(): void { $post_id = self::factory()->post->create( array( 'post_type' => 'wp_knowledge', 'post_status' => 'private', ) ); + $this->assertIsInt( $post_id ); $terms = wp_get_object_terms( $post_id, 'wp_knowledge_type', array( 'fields' => 'slugs' ) ); @@ -68,16 +69,18 @@ public function test_default_type_term_is_assigned_on_save() { * @ticket 65476 * @covers ::wp_knowledge_ensure_default_type_term */ - public function test_existing_type_term_is_preserved_on_save() { + public function test_existing_type_term_is_preserved_on_save(): void { $post_id = self::factory()->post->create( array( 'post_type' => 'wp_knowledge', 'post_status' => 'private', ) ); + $this->assertIsInt( $post_id ); // Assign a non-default term, replacing the `note` fallback from creation. $term = wp_insert_term( 'memory', 'wp_knowledge_type' ); + $this->assertIsArray( $term ); wp_set_object_terms( $post_id, (int) $term['term_id'], 'wp_knowledge_type' ); // A subsequent save must not re-add the `note` fallback. @@ -99,11 +102,12 @@ public function test_existing_type_term_is_preserved_on_save() { * @ticket 65476 * @covers ::wp_knowledge_maybe_map_term_label */ - public function test_registered_slug_term_gets_mapped_label() { + public function test_registered_slug_term_gets_mapped_label(): void { $term = wp_insert_term( 'guideline', 'wp_knowledge_type' ); - $this->assertNotWPError( $term ); + $this->assertIsArray( $term ); $created = get_term( $term['term_id'], 'wp_knowledge_type' ); + $this->assertInstanceOf( WP_Term::class, $created ); $this->assertSame( 'guideline', $created->slug ); $this->assertSame( 'Guideline', $created->name ); @@ -115,11 +119,12 @@ public function test_registered_slug_term_gets_mapped_label() { * @ticket 65476 * @covers ::wp_knowledge_maybe_map_term_label */ - public function test_custom_label_is_not_overwritten() { + public function test_custom_label_is_not_overwritten(): void { $term = wp_insert_term( 'My Custom Type', 'wp_knowledge_type', array( 'slug' => 'guideline' ) ); - $this->assertNotWPError( $term ); + $this->assertIsArray( $term ); $created = get_term( $term['term_id'], 'wp_knowledge_type' ); + $this->assertInstanceOf( WP_Term::class, $created ); $this->assertSame( 'guideline', $created->slug ); $this->assertSame( 'My Custom Type', $created->name ); @@ -131,11 +136,12 @@ public function test_custom_label_is_not_overwritten() { * @ticket 65476 * @covers ::wp_knowledge_maybe_map_term_label */ - public function test_label_mapping_is_scoped_to_knowledge_taxonomy() { + public function test_label_mapping_is_scoped_to_knowledge_taxonomy(): void { $term = wp_insert_term( 'guideline', 'category' ); - $this->assertNotWPError( $term ); + $this->assertIsArray( $term ); $created = get_term( $term['term_id'], 'category' ); + $this->assertInstanceOf( WP_Term::class, $created ); $this->assertSame( 'guideline', $created->name ); } @@ -147,7 +153,7 @@ public function test_label_mapping_is_scoped_to_knowledge_taxonomy() { * @ticket 65476 * @covers ::wp_knowledge_maybe_map_term_label */ - public function test_label_is_resolved_in_site_locale() { + public function test_label_is_resolved_in_site_locale(): void { // Simulate a non-site request locale. Priority 1 runs before the locale // switcher (priority 10), so the mapping's switch still wins. $request_locale = 'de_DE'; diff --git a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php index 0069c503bfba6..fa30870e27bf9 100644 --- a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php +++ b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php @@ -12,27 +12,16 @@ */ class Tests_REST_WpRestKnowledgeController extends WP_Test_REST_Controller_Testcase { - /** - * @var int - */ - protected static $admin_id; + protected static int $admin_id; - /** - * @var int - */ - protected static $contributor_id; + protected static int $contributor_id; - /** - * @var int - */ - protected static $subscriber_id; + protected static int $subscriber_id; /** * A private knowledge row owned by the administrator. - * - * @var int */ - protected static $admin_private; + protected static int $admin_private; public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); @@ -49,7 +38,7 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { ); } - public static function wpTearDownAfterClass() { + public static function wpTearDownAfterClass(): void { self::delete_user( self::$admin_id ); self::delete_user( self::$contributor_id ); self::delete_user( self::$subscriber_id ); @@ -76,7 +65,7 @@ private function create_knowledge_post( int $author_id ): int { /** * @ticket 65476 */ - public function test_register_routes() { + public function test_register_routes(): void { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/knowledge', $routes ); @@ -94,7 +83,7 @@ public function test_register_routes() { /** * @ticket 65476 */ - public function test_context_param() { + public function test_context_param(): void { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/knowledge' ); @@ -108,7 +97,7 @@ public function test_context_param() { /** * @ticket 65476 */ - public function test_get_items() { + public function test_get_items(): void { wp_set_current_user( self::$admin_id ); // The collection defaults to the `publish` status; knowledge rows are @@ -132,7 +121,7 @@ public function test_get_items() { /** * @ticket 65476 */ - public function test_get_items_requires_authentication() { + public function test_get_items_requires_authentication(): void { wp_set_current_user( 0 ); $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge' ); $response = rest_get_server()->dispatch( $request ); @@ -146,7 +135,7 @@ public function test_get_items_requires_authentication() { /** * @ticket 65476 */ - public function test_get_item() { + public function test_get_item(): void { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge/' . self::$admin_private ); @@ -159,7 +148,7 @@ public function test_get_item() { /** * @ticket 65476 */ - public function test_contributor_cannot_read_others_private_row() { + public function test_contributor_cannot_read_others_private_row(): void { wp_set_current_user( self::$contributor_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge/' . self::$admin_private ); @@ -172,7 +161,7 @@ public function test_contributor_cannot_read_others_private_row() { /** * @ticket 65476 */ - public function test_create_item() { + public function test_create_item(): void { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); @@ -188,7 +177,7 @@ public function test_create_item() { /** * @ticket 65476 */ - public function test_contributor_create_defaults_to_private() { + public function test_contributor_create_defaults_to_private(): void { wp_set_current_user( self::$contributor_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); @@ -202,7 +191,7 @@ public function test_contributor_create_defaults_to_private() { /** * @ticket 65476 */ - public function test_contributor_cannot_publish() { + public function test_contributor_cannot_publish(): void { wp_set_current_user( self::$contributor_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); @@ -220,7 +209,7 @@ public function test_contributor_cannot_publish() { /** * @ticket 65476 */ - public function test_update_item() { + public function test_update_item(): void { wp_set_current_user( self::$admin_id ); $post_id = $this->create_knowledge_post( self::$admin_id ); @@ -238,7 +227,7 @@ public function test_update_item() { * * @ticket 65476 */ - public function test_contributor_can_update_own_row() { + public function test_contributor_can_update_own_row(): void { wp_set_current_user( self::$contributor_id ); $post_id = $this->create_knowledge_post( self::$contributor_id ); @@ -256,7 +245,7 @@ public function test_contributor_can_update_own_row() { * * @ticket 65476 */ - public function test_contributor_cannot_update_others_row() { + public function test_contributor_cannot_update_others_row(): void { wp_set_current_user( self::$contributor_id ); $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge/' . self::$admin_private ); @@ -269,7 +258,7 @@ public function test_contributor_cannot_update_others_row() { /** * @ticket 65476 */ - public function test_delete_item() { + public function test_delete_item(): void { wp_set_current_user( self::$admin_id ); $post_id = $this->create_knowledge_post( self::$admin_id ); @@ -287,7 +276,7 @@ public function test_delete_item() { * * @ticket 65476 */ - public function test_contributor_can_delete_own_row() { + public function test_contributor_can_delete_own_row(): void { wp_set_current_user( self::$contributor_id ); $post_id = $this->create_knowledge_post( self::$contributor_id ); @@ -305,7 +294,7 @@ public function test_contributor_can_delete_own_row() { * * @ticket 65476 */ - public function test_contributor_cannot_delete_others_row() { + public function test_contributor_cannot_delete_others_row(): void { wp_set_current_user( self::$contributor_id ); $request = new WP_REST_Request( 'DELETE', '/wp/v2/knowledge/' . self::$admin_private ); @@ -319,7 +308,7 @@ public function test_contributor_cannot_delete_others_row() { /** * @ticket 65476 */ - public function test_prepare_item() { + public function test_prepare_item(): void { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/knowledge/' . self::$admin_private ); @@ -337,7 +326,7 @@ public function test_prepare_item() { /** * @ticket 65476 */ - public function test_get_item_schema() { + public function test_get_item_schema(): void { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/knowledge' ); From b8aa419498c9e9c891530b5c2582751d75da436f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 9 Jul 2026 12:29:37 -0700 Subject: [PATCH 17/20] Fix phpstan errors in tests --- .../phpunit/tests/knowledge/capabilities.php | 45 +++++++---- .../rest-api/wpRestKnowledgeController.php | 75 ++++++++++++++----- 2 files changed, 85 insertions(+), 35 deletions(-) diff --git a/tests/phpunit/tests/knowledge/capabilities.php b/tests/phpunit/tests/knowledge/capabilities.php index 6c4d611a42210..063a49e576a42 100644 --- a/tests/phpunit/tests/knowledge/capabilities.php +++ b/tests/phpunit/tests/knowledge/capabilities.php @@ -35,31 +35,44 @@ class Tests_Knowledge_Capabilities extends WP_UnitTestCase { private static int $others_private; public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ): void { + $throw_if_not_int = static function ( $value ): int { + if ( ! is_int( $value ) ) { + throw new Exception( 'Value is not an int.' ); + } + return $value; + }; + foreach ( array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ) as $role ) { - self::$users[ $role ] = $factory->user->create( array( 'role' => $role ) ); + self::$users[ $role ] = $throw_if_not_int( $factory->user->create( array( 'role' => $role ) ) ); } - self::$own_private = $factory->post->create( - array( - 'post_type' => 'wp_knowledge', - 'post_status' => 'private', - 'post_author' => self::$users['contributor'], + self::$own_private = $throw_if_not_int( + $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$users['contributor'], + ) ) ); - self::$own_published = $factory->post->create( - array( - 'post_type' => 'wp_knowledge', - 'post_status' => 'publish', - 'post_author' => self::$users['contributor'], + self::$own_published = $throw_if_not_int( + $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'publish', + 'post_author' => self::$users['contributor'], + ) ) ); - self::$others_private = $factory->post->create( - array( - 'post_type' => 'wp_knowledge', - 'post_status' => 'private', - 'post_author' => self::$users['author'], + self::$others_private = $throw_if_not_int( + $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$users['author'], + ) ) ); } diff --git a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php index fa30870e27bf9..ee50098adf248 100644 --- a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php +++ b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php @@ -23,17 +23,26 @@ class Tests_REST_WpRestKnowledgeController extends WP_Test_REST_Controller_Testc */ protected static int $admin_private; - public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { - self::$admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); - self::$contributor_id = $factory->user->create( array( 'role' => 'contributor' ) ); - self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); - - self::$admin_private = $factory->post->create( - array( - 'post_type' => 'wp_knowledge', - 'post_status' => 'private', - 'post_author' => self::$admin_id, - 'post_title' => 'Admin private knowledge', + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ): void { + $throw_if_not_int = static function ( $value ): int { + if ( ! is_int( $value ) ) { + throw new Exception( 'Value is not an int.' ); + } + return $value; + }; + + self::$admin_id = $throw_if_not_int( $factory->user->create( array( 'role' => 'administrator' ) ) ); + self::$contributor_id = $throw_if_not_int( $factory->user->create( array( 'role' => 'contributor' ) ) ); + self::$subscriber_id = $throw_if_not_int( $factory->user->create( array( 'role' => 'subscriber' ) ) ); + + self::$admin_private = $throw_if_not_int( + $factory->post->create( + array( + 'post_type' => 'wp_knowledge', + 'post_status' => 'private', + 'post_author' => self::$admin_id, + 'post_title' => 'Admin private knowledge', + ) ) ); } @@ -49,10 +58,12 @@ public static function wpTearDownAfterClass(): void { * Creates a private knowledge row for the given author. * * @param int $author_id Author user ID. + * * @return int Post ID. + * @throws Exception In the unlikely event that a factory is unable to create a post. */ private function create_knowledge_post( int $author_id ): int { - return self::factory()->post->create( + $post = self::factory()->post->create( array( 'post_type' => 'wp_knowledge', 'post_status' => 'private', @@ -60,6 +71,10 @@ private function create_knowledge_post( int $author_id ): int { 'post_title' => 'Knowledge row', ) ); + if ( ! is_int( $post ) ) { + throw new Exception( 'Factory post creation failure' ); + } + return $post; } /** @@ -89,8 +104,11 @@ public function test_context_param(): void { $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/knowledge' ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertTrue( isset( $data['endpoints'][0]['args']['context']['default'] ) ); // @phpstan-ignore offsetAccess.nonOffsetAccessible, offsetAccess.nonOffsetAccessible, offsetAccess.nonOffsetAccessible, offsetAccess.nonOffsetAccessible $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertTrue( isset( $data['endpoints'][0]['args']['context']['enum'] ) ); // @phpstan-ignore offsetAccess.nonOffsetAccessible $this->assertSame( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); } @@ -142,7 +160,10 @@ public function test_get_item(): void { $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); - $this->assertSame( self::$admin_private, $response->get_data()['id'] ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'id', $data ); + $this->assertSame( self::$admin_private, $data['id'] ); } /** @@ -170,6 +191,8 @@ public function test_create_item(): void { $this->assertSame( 201, $response->get_status() ); $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'status', $data ); // With no status supplied, new rows default to private rather than draft. $this->assertSame( 'private', $data['status'] ); } @@ -183,9 +206,12 @@ public function test_contributor_create_defaults_to_private(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); $request->set_body_params( array( 'title' => 'Created by contributor' ) ); $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'status', $data ); $this->assertSame( 201, $response->get_status() ); - $this->assertSame( 'private', $response->get_data()['status'] ); + $this->assertSame( 'private', $data['status'] ); } /** @@ -219,7 +245,10 @@ public function test_update_item(): void { $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); - $this->assertSame( 'Updated title', $response->get_data()['title']['raw'] ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertTrue( isset( $data['title']['raw'] ) ); // @phpstan-ignore offsetAccess.nonOffsetAccessible + $this->assertSame( 'Updated title', $data['title']['raw'] ); } /** @@ -237,7 +266,10 @@ public function test_contributor_can_update_own_row(): void { $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); - $this->assertSame( 'Updated by contributor', $response->get_data()['title']['raw'] ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertTrue( isset( $data['title']['raw'] ) ); // @phpstan-ignore offsetAccess.nonOffsetAccessible + $this->assertSame( 'Updated by contributor', $data['title']['raw'] ); } /** @@ -315,6 +347,7 @@ public function test_prepare_item(): void { $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); + $this->assertIsArray( $data ); $this->assertSame( 200, $response->get_status() ); $this->assertArrayHasKey( 'id', $data ); @@ -329,9 +362,13 @@ public function test_prepare_item(): void { public function test_get_item_schema(): void { wp_set_current_user( self::$admin_id ); - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/knowledge' ); - $response = rest_get_server()->dispatch( $request ); - $properties = $response->get_data()['schema']['properties']; + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/knowledge' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertTrue( isset( $data['schema']['properties'] ) ); // @phpstan-ignore offsetAccess.nonOffsetAccessible + $properties = $data['schema']['properties']; + $this->assertIsArray( $properties ); $this->assertArrayHasKey( 'id', $properties ); $this->assertArrayHasKey( 'title', $properties ); From 2b6582c3b1c929d8802f340f5baf635300ebef50 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 13 Jul 2026 07:09:07 +0200 Subject: [PATCH 18/20] Knowledge: Enforce the default type term after all REST term writes. Apply the `note` fallback on `wp_after_insert_post` instead of `save_post_wp_knowledge`. The REST controller writes terms in handle_terms() after the post row is inserted, so a request that sent an empty `wp_knowledge_type` array cleared the fallback that `save_post` had just added, leaving the row with no type. `wp_after_insert_post` runs after every term write, so the fallback is re-applied in that case. Add REST tests for the empty-array path on both create and update. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/default-filters.php | 2 +- src/wp-includes/knowledge.php | 18 ++++--- .../rest-api/wpRestKnowledgeController.php | 51 +++++++++++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 4d69eaf1debf3..9c301addb4a56 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -790,7 +790,7 @@ add_filter( 'rest_wp_navigation_item_schema', array( 'WP_Navigation_Fallback', 'update_wp_navigation_post_schema' ) ); // wp_knowledge post type. -add_action( 'save_post_wp_knowledge', 'wp_knowledge_ensure_default_type_term' ); +add_action( 'wp_after_insert_post', 'wp_knowledge_ensure_default_type_term', 10, 2 ); add_filter( 'wp_insert_term_data', 'wp_knowledge_maybe_map_term_label', 10, 2 ); // Fluid typography. diff --git a/src/wp-includes/knowledge.php b/src/wp-includes/knowledge.php index 118ed0909c25e..d9b99f85ff336 100644 --- a/src/wp-includes/knowledge.php +++ b/src/wp-includes/knowledge.php @@ -65,19 +65,23 @@ function wp_knowledge_types(): array { } /** - * Assigns the `note` fallback term when a knowledge post is saved without a type. + * Ensures every knowledge row carries at least one type term. * - * Hooked to the `save_post_wp_knowledge` action so that every knowledge row has - * at least one `wp_knowledge_type` term. Uses get_the_terms() so the check is - * served by the object term cache. + * Knowledge rows are classified by their `wp_knowledge_type` terms, so a row + * saved without one would belong to no type at all. This assigns the `note` + * fallback in that case, keeping every row classified. + * + * Hooked to `wp_after_insert_post` so it runs after the row's terms are saved, + * when the final set of terms is known. * * @since 7.1.0 * @access private * - * @param int $post_id Saved post ID. + * @param int $post_id Saved post ID. + * @param WP_Post $post Saved post object. */ -function wp_knowledge_ensure_default_type_term( int $post_id ): void { - if ( wp_is_post_revision( $post_id ) ) { +function wp_knowledge_ensure_default_type_term( int $post_id, WP_Post $post ): void { + if ( 'wp_knowledge' !== $post->post_type ) { return; } diff --git a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php index ee50098adf248..3b33f2c3d23a1 100644 --- a/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php +++ b/tests/phpunit/tests/rest-api/wpRestKnowledgeController.php @@ -197,6 +197,57 @@ public function test_create_item(): void { $this->assertSame( 'private', $data['status'] ); } + /** + * An empty `wp_knowledge_type` array on create must not leave the row without a type. + * + * The controller assigns terms in handle_terms() after the post row is + * inserted, so an empty array clears any terms. The `note` fallback is + * re-applied on `wp_after_insert_post`, which runs after that write. + * + * @ticket 65476 + */ + public function test_create_item_with_empty_type_falls_back_to_note(): void { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge' ); + $request->set_body_params( + array( + 'title' => 'Created without a type', + 'wp_knowledge_type' => array(), + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'id', $data ); + + $terms = wp_get_object_terms( (int) $data['id'], 'wp_knowledge_type', array( 'fields' => 'slugs' ) ); + $this->assertSame( array( 'note' ), $terms ); + } + + /** + * An empty `wp_knowledge_type` array on update must restore the `note` fallback. + * + * @ticket 65476 + */ + public function test_update_item_with_empty_type_restores_note(): void { + wp_set_current_user( self::$admin_id ); + + $post_id = $this->create_knowledge_post( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/knowledge/' . $post_id ); + $request->set_body_params( array( 'wp_knowledge_type' => array() ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $terms = wp_get_object_terms( $post_id, 'wp_knowledge_type', array( 'fields' => 'slugs' ) ); + $this->assertSame( array( 'note' ), $terms ); + } + /** * @ticket 65476 */ From 8d32e60bd6833f613635ecd893164f445a116165 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 13 Jul 2026 09:24:15 +0200 Subject: [PATCH 19/20] Knowledge: Move @since tags from 7.1.0 to 7.2.0. The knowledge post type, its type taxonomy, capability filter, and REST controller now target WordPress 7.2. Update the @since tags added by this feature to 7.2.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/capabilities.php | 2 +- src/wp-includes/knowledge.php | 10 +++++----- src/wp-includes/post.php | 2 +- .../class-wp-rest-knowledge-controller.php | 14 +++++++------- src/wp-includes/taxonomy.php | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 6b2e5bbfea02b..3585a6a9af6af 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1376,7 +1376,7 @@ function wp_maybe_grant_site_health_caps( $allcaps, $caps, $args, $user ) { * users' rows is reserved for administrators. Subscribers receive nothing and * are stopped at the post-type door by the `read_knowledge_items` mapping. * - * @since 7.1.0 + * @since 7.2.0 * * @param array $allcaps An array of all the user's capabilities. * @param string[] $caps Required primitive capabilities for the requested capability. diff --git a/src/wp-includes/knowledge.php b/src/wp-includes/knowledge.php index d9b99f85ff336..a8af8ab17f2ce 100644 --- a/src/wp-includes/knowledge.php +++ b/src/wp-includes/knowledge.php @@ -10,7 +10,7 @@ * * @package WordPress * @subpackage Knowledge - * @since 7.1.0 + * @since 7.2.0 */ /** @@ -18,7 +18,7 @@ * * Plugins can register their own types via the {@see 'wp_knowledge_types'} filter. * - * @since 7.1.0 + * @since 7.2.0 * * @return array { * Slug-keyed map of knowledge types. @@ -35,7 +35,7 @@ function wp_knowledge_types(): array { /** * Filters the knowledge types available on this site. * - * @since 7.1.0 + * @since 7.2.0 * * @param array $types { * Slug-keyed map of knowledge types. @@ -74,7 +74,7 @@ function wp_knowledge_types(): array { * Hooked to `wp_after_insert_post` so it runs after the row's terms are saved, * when the final set of terms is known. * - * @since 7.1.0 + * @since 7.2.0 * @access private * * @param int $post_id Saved post ID. @@ -119,7 +119,7 @@ function wp_knowledge_ensure_default_type_term( int $post_id, WP_Post $post ): v * The name is written once and shared by every user, so it is resolved in the * site locale, not the locale of the request that happens to create the term. * - * @since 7.1.0 + * @since 7.2.0 * @access private * * @param array $data Term data to be inserted (keyed by column name). diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 438f127f9bbf6..286b08fb28aad 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -16,7 +16,7 @@ * See {@see 'init'}. * * @since 2.9.0 - * @since 7.1.0 Added the `wp_knowledge` post type. + * @since 7.2.0 Added the `wp_knowledge` post type. */ function create_initial_post_types() { WP_Post_Type::reset_default_labels(); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php index 03f58eebf7b49..6676445bf7ad4 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-knowledge-controller.php @@ -4,7 +4,7 @@ * * @package WordPress * @subpackage REST_API - * @since 7.1.0 + * @since 7.2.0 */ /** @@ -15,7 +15,7 @@ * rows the current user can read, callers without the publish capability may * only use the `private` status, and new rows default to the `private` status. * - * @since 7.1.0 + * @since 7.2.0 * * @see WP_REST_Posts_Controller */ @@ -27,7 +27,7 @@ class WP_REST_Knowledge_Controller extends WP_REST_Posts_Controller { * Knowledge is private storage, so the collection is available only to an * authenticated user with the post type's read capability. * - * @since 7.1.0 + * @since 7.2.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access, WP_Error object otherwise. @@ -52,7 +52,7 @@ public function get_items_permissions_check( $request ) { * Scopes the collection to rows readable by the current user so that the * total count and pagination headers reflect per-user visibility. * - * @since 7.1.0 + * @since 7.2.0 * * @param array $prepared_args Optional. Prepared WP_Query arguments. Default empty array. * @param WP_REST_Request|null $request Optional. Full details about the request. @@ -71,7 +71,7 @@ protected function prepare_items_query( $prepared_args = array(), $request = nul * A row is readable only when the current user passes the `read_post` * capability check, which accounts for the row's author and status. * - * @since 7.1.0 + * @since 7.2.0 * * @param WP_Post $post Post object. * @return bool Whether the post can be read. @@ -89,7 +89,7 @@ public function check_read_permission( $post ) { * * Callers without the publish capability may only set the `private` status. * - * @since 7.1.0 + * @since 7.2.0 * * @param string $post_status The post status. * @param WP_Post_Type $post_type The post type object. @@ -116,7 +116,7 @@ protected function handle_status_param( $post_status, $post_type ) { * * New rows default to the `private` status when no status is supplied. * - * @since 7.1.0 + * @since 7.2.0 * * @param WP_REST_Request $request Request object. * @return stdClass|WP_Error Post object or WP_Error. diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index bb085cf7585ef..b2aac63438497 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -19,7 +19,7 @@ * * @since 2.8.0 * @since 5.9.0 Added `'wp_template_part_area'` taxonomy. - * @since 7.1.0 Added `'wp_knowledge_type'` taxonomy. + * @since 7.2.0 Added `'wp_knowledge_type'` taxonomy. * * @global WP_Rewrite $wp_rewrite WordPress rewrite component. */ From c6023eb1f29049df7a47f6cd430515e93a0e1a2c Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 13 Jul 2026 09:34:35 +0200 Subject: [PATCH 20/20] Knowledge: Drop the `memory` default type. Reduce the built-in knowledge types to `guideline` and `note`. Plugins can still add their own types through the `wp_knowledge_types` filter. Update the types tests to match, using `guideline` as the non-default term. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/knowledge.php | 5 +---- tests/phpunit/tests/knowledge/types.php | 6 ++---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/knowledge.php b/src/wp-includes/knowledge.php index a8af8ab17f2ce..dc144966c5871 100644 --- a/src/wp-includes/knowledge.php +++ b/src/wp-includes/knowledge.php @@ -4,7 +4,7 @@ * * The Knowledge post type is a private-by-default storage primitive. Individual * rows are classified by one or more terms in the `wp_knowledge_type` taxonomy - * (for example "guideline", "memory", or "note"). This file holds the type + * (for example "guideline" or "note"). This file holds the type * registry, the default-term fallback applied on save, and the helper that * gives lazily created type terms a human-readable label. * @@ -54,9 +54,6 @@ function wp_knowledge_types(): array { 'guideline' => array( 'title' => _x( 'Guideline', 'knowledge type' ), ), - 'memory' => array( - 'title' => _x( 'Memory', 'knowledge type' ), - ), 'note' => array( 'title' => _x( 'Note', 'knowledge type' ), ), diff --git a/tests/phpunit/tests/knowledge/types.php b/tests/phpunit/tests/knowledge/types.php index ef6f9232547bd..4ab097f43aef9 100644 --- a/tests/phpunit/tests/knowledge/types.php +++ b/tests/phpunit/tests/knowledge/types.php @@ -17,11 +17,9 @@ public function test_default_types_are_registered(): void { $types = wp_knowledge_types(); $this->assertArrayHasKey( 'guideline', $types ); - $this->assertArrayHasKey( 'memory', $types ); $this->assertArrayHasKey( 'note', $types ); $this->assertSame( 'Guideline', $types['guideline']['title'] ); - $this->assertSame( 'Memory', $types['memory']['title'] ); $this->assertSame( 'Note', $types['note']['title'] ); } @@ -79,7 +77,7 @@ public function test_existing_type_term_is_preserved_on_save(): void { $this->assertIsInt( $post_id ); // Assign a non-default term, replacing the `note` fallback from creation. - $term = wp_insert_term( 'memory', 'wp_knowledge_type' ); + $term = wp_insert_term( 'guideline', 'wp_knowledge_type' ); $this->assertIsArray( $term ); wp_set_object_terms( $post_id, (int) $term['term_id'], 'wp_knowledge_type' ); @@ -93,7 +91,7 @@ public function test_existing_type_term_is_preserved_on_save(): void { $terms = wp_get_object_terms( $post_id, 'wp_knowledge_type', array( 'fields' => 'slugs' ) ); - $this->assertSame( array( 'memory' ), $terms ); + $this->assertSame( array( 'guideline' ), $terms ); } /**