diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 9f53887d400de..c20752eef5d89 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -42,7 +42,6 @@ includes: - tests/phpstan/baselines/empty.offset.neon - tests/phpstan/baselines/empty.property.neon - tests/phpstan/baselines/empty.variable.neon - - tests/phpstan/baselines/foreach.nonIterable.neon - tests/phpstan/baselines/function.alreadyNarrowedType.neon - tests/phpstan/baselines/function.impossibleType.neon - tests/phpstan/baselines/function.resultUnused.neon diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index b37e84768d9f7..88fecd08c868c 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -797,13 +797,14 @@ public function remove_supports() { * Removes any rewrite rules, permastructs, and rules for the post type. * * @since 4.6.0 + * @since 7.2.0 Registered meta capabilities are no longer removed here. They are rebuilt + * from the post types that remain by {@see unregister_post_type()}. * - * @global WP_Rewrite $wp_rewrite WordPress rewrite component. - * @global WP $wp Current WordPress environment instance. - * @global array $post_type_meta_caps Used to remove meta capabilities. + * @global WP_Rewrite $wp_rewrite WordPress rewrite component. + * @global WP $wp Current WordPress environment instance. */ public function remove_rewrite_rules() { - global $wp, $wp_rewrite, $post_type_meta_caps; + global $wp, $wp_rewrite; // Remove query var. if ( false !== $this->query_var ) { @@ -820,11 +821,6 @@ public function remove_rewrite_rules() { } } } - - // Remove registered custom meta capabilities. - foreach ( $this->cap as $cap ) { - unset( $post_type_meta_caps[ $cap ] ); - } } /** diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 3f73e5463dbfe..646ca4bb92acb 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -1917,20 +1917,20 @@ function register_post_type( $post_type, $args = array() ) { * * @since 4.5.0 * - * @global array $wp_post_types List of post types. + * @global array $post_type_meta_caps Used to store meta capabilities. + * @global array $wp_post_types List of post types. * * @param string $post_type Post type to unregister. * @return true|WP_Error True on success, WP_Error on failure or if the post type doesn't exist. */ function unregister_post_type( $post_type ) { - global $wp_post_types; + global $post_type_meta_caps, $wp_post_types; - if ( ! post_type_exists( $post_type ) ) { + $post_type_object = get_post_type_object( $post_type ); + if ( ! $post_type_object ) { return new WP_Error( 'invalid_post_type', __( 'Invalid post type.' ) ); } - $post_type_object = get_post_type_object( $post_type ); - // Do not allow unregistering internal post types. if ( $post_type_object->_builtin ) { return new WP_Error( 'invalid_post_type', __( 'Unregistering a built-in post type is not allowed' ) ); @@ -1944,6 +1944,20 @@ function unregister_post_type( $post_type ) { unset( $wp_post_types[ $post_type ] ); + /* + * Rebuild the meta capabilities of the post types that remain. + * + * They are keyed by the custom capability name, so a single entry may be owed to any + * number of registered post types. Removing the entries for this post type alone could + * therefore remove entries that the others still depend on. + */ + $post_type_meta_caps = array(); + foreach ( $wp_post_types as $registered_post_type ) { + if ( $registered_post_type->map_meta_cap ) { + _post_type_meta_capabilities( get_object_vars( $registered_post_type->cap ) ); + } + } + /** * Fires after a post type was unregistered. * @@ -2081,16 +2095,20 @@ function get_post_type_capabilities( $args ) { } /** - * Stores or returns a list of post type meta caps for map_meta_cap(). + * Stores a list of post type meta caps for {@see map_meta_cap()}. * * @since 3.1.0 + * @since 4.5.0 The list moved to the `$post_type_meta_caps` global and the function + * no longer returns it when called without arguments. + * @since 7.2.0 The `$capabilities` parameter defaults to an empty array rather than `null`. * @access private * - * @global array $post_type_meta_caps Used to store meta capabilities. + * @global array $post_type_meta_caps Used to store meta capabilities. * - * @param string[] $capabilities Post type meta capabilities. + * @param array $capabilities Map of core meta capability name to the custom + * capability name it is registered under. */ -function _post_type_meta_capabilities( $capabilities = null ) { +function _post_type_meta_capabilities( $capabilities = array() ): void { global $post_type_meta_caps; foreach ( $capabilities as $core => $custom ) { diff --git a/tests/phpstan/baselines/foreach.nonIterable.neon b/tests/phpstan/baselines/foreach.nonIterable.neon deleted file mode 100644 index be8bba17a113c..0000000000000 --- a/tests/phpstan/baselines/foreach.nonIterable.neon +++ /dev/null @@ -1,25 +0,0 @@ -# PHPStan baseline for the `foreach.nonIterable` errors in WordPress core. -# -# https://phpstan.org/error-identifiers/foreach.nonIterable -# -# Each entry is scoped to a single file and carries an exact occurrence count, -# so that a new instance is reported as a new error rather than being absorbed -# silently. Fixing an occurrence therefore means decrementing or removing its -# entry here as part of the same change. -# -# The goal is to empty this file and delete it, along with the `includes` entry -# for it in phpstan.neon.dist. -# -# Generated by `composer phpstan:baselines`. Do not edit by hand; regenerate with -# -# composer phpstan:baselines -- --identifier=foreach.nonIterable -# -# which reruns the analysis with this file suppressed so the errors surface again. - -parameters: - ignoreErrors: - - - message: '#^Argument of an invalid type stdClass supplied for foreach, only iterables are supported\.$#' - identifier: foreach.nonIterable - count: 1 - path: ../../../src/wp-includes/class-wp-post-type.php diff --git a/tests/phpunit/tests/post/types.php b/tests/phpunit/tests/post/types.php index 5ae45c67e1044..5cfeaf4785396 100644 --- a/tests/phpunit/tests/post/types.php +++ b/tests/phpunit/tests/post/types.php @@ -13,6 +13,20 @@ class Tests_Post_Types extends WP_UnitTestCase { */ public $post_type; + /** + * Author user ID. + */ + public static int $author_id; + + /** + * Sets up shared fixtures. + * + * @param WP_UnitTest_Factory $factory Factory instance. + */ + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$author_id = $factory->user->create( array( 'role' => 'author' ) ); + } + /** * Set up. * @@ -419,6 +433,146 @@ public function test_unregister_post_type_removes_custom_meta_capabilities() { $this->assertArrayNotHasKey( 'edit_bar', $post_type_meta_caps ); } + /** + * Tests that meta capabilities shared with another registered post type are retained. + * + * Meta capabilities are stored keyed by the custom capability name, so post types + * sharing a capability type resolve to the same entries. Unregistering one of them + * must not remove the entries the others still rely on. + * + * @ticket 66008 + * + * @global array $post_type_meta_caps Used to store meta capabilities. + */ + public function test_unregister_post_type_retains_meta_capabilities_shared_with_another_post_type() { + global $post_type_meta_caps; + + $args = array( + 'public' => true, + 'capability_type' => 'publication', + 'map_meta_cap' => true, + ); + + register_post_type( 'book', $args ); + register_post_type( 'magazine', $args ); + + $this->assertSame( 'read_post', $post_type_meta_caps['read_publication'], 'The read meta capability was not registered.' ); + $this->assertSame( 'delete_post', $post_type_meta_caps['delete_publication'], 'The delete meta capability was not registered.' ); + $this->assertSame( 'edit_post', $post_type_meta_caps['edit_publication'], 'The edit meta capability was not registered.' ); + + $this->assertTrue( unregister_post_type( 'book' ) ); + + $this->assertSame( 'read_post', $post_type_meta_caps['read_publication'], 'The read meta capability of the remaining post type was removed.' ); + $this->assertSame( 'delete_post', $post_type_meta_caps['delete_publication'], 'The delete meta capability of the remaining post type was removed.' ); + $this->assertSame( 'edit_post', $post_type_meta_caps['edit_publication'], 'The edit meta capability of the remaining post type was removed.' ); + } + + /** + * Tests that a remaining post type's meta capabilities still map down to primitive capabilities. + * + * @ticket 66008 + */ + public function test_unregister_post_type_retains_meta_capability_mapping_for_another_post_type() { + $args = array( + 'public' => true, + 'capability_type' => 'publication', + 'map_meta_cap' => true, + ); + + register_post_type( 'book', $args ); + register_post_type( 'magazine', $args ); + + $post_id = self::factory()->post->create( + array( + 'post_type' => 'magazine', + 'post_status' => 'publish', + 'post_author' => self::$author_id, + ) + ); + + $this->assertSame( + array( 'edit_published_publications' ), + map_meta_cap( 'edit_publication', self::$author_id, $post_id ), + 'The meta capability did not map to a primitive capability.' + ); + + $this->assertTrue( unregister_post_type( 'book' ) ); + + $this->assertSame( + array( 'edit_published_publications' ), + map_meta_cap( 'edit_publication', self::$author_id, $post_id ), + 'The meta capability of the remaining post type no longer maps to a primitive capability.' + ); + } + + /** + * Tests that a post type which does not map meta capabilities removes none on unregistration. + * + * Such a post type never stores any meta capabilities, so it must not remove the + * identically named entries belonging to the built-in post types. + * + * @ticket 66008 + * + * @global array $post_type_meta_caps Used to store meta capabilities. + */ + public function test_unregister_post_type_retains_meta_capabilities_when_not_mapping_meta_caps() { + global $post_type_meta_caps; + + register_post_type( + 'foo', + array( + 'public' => true, + 'map_meta_cap' => false, + ) + ); + + $this->assertTrue( unregister_post_type( 'foo' ) ); + + $this->assertSame( 'read_post', $post_type_meta_caps['read_post'], 'The built-in read meta capability was removed.' ); + $this->assertSame( 'delete_post', $post_type_meta_caps['delete_post'], 'The built-in delete meta capability was removed.' ); + $this->assertSame( 'edit_post', $post_type_meta_caps['edit_post'], 'The built-in edit meta capability was removed.' ); + } + + /** + * Tests that primitive capabilities are not treated as meta capabilities on unregistration. + * + * Only the read, delete and edit capabilities are stored as meta capabilities. A post type + * using one of those names as a primitive capability must not remove another post type's + * meta capability of the same name. + * + * @ticket 66008 + * + * @global array $post_type_meta_caps Used to store meta capabilities. + */ + public function test_unregister_post_type_retains_meta_capabilities_matching_primitive_capabilities() { + global $post_type_meta_caps; + + register_post_type( + 'book', + array( + 'public' => true, + 'capability_type' => 'book', + 'map_meta_cap' => true, + ) + ); + + // For this post type 'edit_book' is a primitive capability, not a meta capability. + register_post_type( + 'shelf', + array( + 'public' => true, + 'map_meta_cap' => false, + 'capabilities' => array( + 'edit_posts' => 'edit_book', + ), + ) + ); + + $this->assertTrue( unregister_post_type( 'shelf' ) ); + + $this->assertSame( 'edit_post', $post_type_meta_caps['edit_book'], 'The meta capability of another post type was removed.' ); + } + /** * @ticket 14761 */