diff --git a/src/wp-includes/ms-blogs.php b/src/wp-includes/ms-blogs.php index adb20da2678bc..d791dd2f4526d 100644 --- a/src/wp-includes/ms-blogs.php +++ b/src/wp-includes/ms-blogs.php @@ -889,12 +889,13 @@ function _update_blog_date_on_post_delete( $post_id ) { * * @since 4.0.0 * @since 6.2.0 Added the `$post` parameter. + * @since 7.2.0 The `$post` parameter isn't always guaranteed to be provided. * - * @param int $post_id Post ID. - * @param WP_Post $post Post object. + * @param int $post_id Post ID. + * @param WP_Post|null $post Optional. Post object. Default null. */ -function _update_posts_count_on_delete( $post_id, $post ) { - if ( ! $post || 'publish' !== $post->post_status || 'post' !== $post->post_type ) { +function _update_posts_count_on_delete( $post_id, $post = null ) { + if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status || 'post' !== $post->post_type ) { return; } diff --git a/src/wp-includes/query.php b/src/wp-includes/query.php index 212ac1619e87f..f53cd05405f97 100644 --- a/src/wp-includes/query.php +++ b/src/wp-includes/query.php @@ -1534,6 +1534,12 @@ function wp_random_content_redirect(): void { * unpublished, renamed, moved or deleted. * * Runs on the {@see 'wp_after_insert_post'} and {@see 'after_delete_post'} actions. + * Third-party code may fire these actions with unexpected values, so the parameters are + * not type declared. When `$post` is omitted it is looked up from `$post_id`. Passing + * anything other than a `WP_Post` as `$post`, or a post that cannot be looked up, is + * incorrect usage and nothing is flushed. `$post_before` is only considered on + * `wp_after_insert_post`, where passing anything other than a `WP_Post` or null is + * incorrect usage and the value is ignored. * * @since 7.2.0 * @@ -1541,21 +1547,59 @@ function wp_random_content_redirect(): void { * @global WP_Rewrite $wp_rewrite WordPress rewrite component. * * @param int $post_id Post ID. - * @param WP_Post $post Post object. + * @param WP_Post|null $post Optional. Post object. Looked up from `$post_id` when null. Default null. * @param bool $update Optional. Whether this is an existing post being updated. Default false. * @param WP_Post|null $post_before Optional. Post object before the update, null for new posts. Default null. */ -function wp_random_content_flush_rewrite_rules_for_post( int $post_id, WP_Post $post, bool $update = false, ?WP_Post $post_before = null ): void { +function wp_random_content_flush_rewrite_rules_for_post( $post_id, $post = null, $update = false, $post_before = null ): void { global $wp_rewrite; + // Only look up a real ID: get_post() falls back to the global post for an empty value. + if ( null === $post && (int) $post_id > 0 ) { + $post = get_post( (int) $post_id ); + } + + if ( ! $post instanceof WP_Post ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: 1: $post, 2: WP_Post */ + __( 'The %1$s argument must be a %2$s object.' ), + '$post', + 'WP_Post' + ), + '7.2.0' + ); + return; + } + + $posts = array( $post ); + + // Only wp_after_insert_post passes the previous post object, which matters when it was renamed or moved. + if ( 'wp_after_insert_post' === current_action() ) { + if ( $post_before instanceof WP_Post ) { + $posts[] = $post_before; + } elseif ( null !== $post_before ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: 1: $post_before, 2: WP_Post */ + __( 'The %1$s argument must be a %2$s object or null.' ), + '$post_before', + 'WP_Post' + ), + '7.2.0' + ); + } + } + if ( ! $wp_rewrite->using_permalinks() || ! wp_is_random_content_redirect_enabled() ) { return; } - foreach ( array( $post, $post_before ) as $page ) { + foreach ( $posts as $page ) { if ( - $page instanceof WP_Post - && $wp_rewrite->get_random_base() === $page->post_name + $wp_rewrite->get_random_base() === $page->post_name && ( ( 'page' === $page->post_type && 0 === (int) $page->post_parent ) || ( 'post' === $page->post_type && $wp_rewrite->post_permalinks_share_random_base() ) diff --git a/tests/phpunit/tests/query/randomContentRedirect.php b/tests/phpunit/tests/query/randomContentRedirect.php index d8854593000f6..398fad717d735 100644 --- a/tests/phpunit/tests/query/randomContentRedirect.php +++ b/tests/phpunit/tests/query/randomContentRedirect.php @@ -232,6 +232,20 @@ protected function create_post( array $args = array() ): int { ); } + /** + * Creates a published post and returns the post object. + * + * @param array $args Optional. Post arguments. + * @return WP_Post Post object. + */ + protected function create_post_object( array $args = array() ): WP_Post { + $post = get_post( $this->create_post( $args ) ); + + $this->assertInstanceOf( WP_Post::class, $post ); + + return $post; + } + /** * Asserts that the request redirected to one of the given posts. * @@ -1234,6 +1248,198 @@ public function test_saving_unrelated_pages_does_not_flush_rewrite_rules(): void $this->assertSame( 0, $flushes->get_call_count(), 'Only pages and posts at the random base should flush the rewrite rules.' ); } + /** + * @dataProvider data_invalid_arguments + * + * @param array $args The arguments to call the function with. + */ + public function test_invalid_arguments_do_not_flush_rewrite_rules( array $args ): void { + // The data provider cannot query the database, so PHP_INT_MAX stands in for a nonexistent post ID. + $args = array_map( + function ( $arg ) { + return PHP_INT_MAX === $arg ? $this->get_nonexistent_post_id() : $arg; + }, + $args + ); + + $this->setExpectedIncorrectUsage( 'wp_random_content_flush_rewrite_rules_for_post' ); + + $flushes = new MockAction(); + add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) ); + + wp_random_content_flush_rewrite_rules_for_post( ...$args ); // @phpstan-ignore argument.type (Intentionally passing invalid values.) + + $this->assertSame( 0, $flushes->get_call_count(), 'Invalid arguments should not flush the rewrite rules.' ); + } + + /** + * Data provider. + * + * Core never fires the `wp_after_insert_post` and `after_delete_post` actions with invalid + * arguments, but third-party code can. Doing so is incorrect usage and must not cause a + * fatal error. + * + * `PHP_INT_MAX` is replaced with a post ID that is verified not to exist when the test runs. + * + * @return array}> + */ + public function data_invalid_arguments(): array { + return array( + 'nulls' => array( array( null, null, false, null ) ), + 'strings' => array( array( 'random', 'random', 'yes', 'random' ) ), + 'arrays' => array( array( array(), array( 'post_name' => 'random' ), 1, array() ) ), + 'nulls without optional values' => array( array( null, null ) ), + 'strings without optional values' => array( array( 'random', 'random' ) ), + 'post ID of zero without a post' => array( array( 0 ) ), + 'nonexistent post ID without a post' => array( array( PHP_INT_MAX ) ), + ); + } + + /** + * Returns a post ID that does not exist. + * + * Starts at `PHP_INT_MAX` and decrements until no post is found. + * + * @return int Post ID. + */ + protected function get_nonexistent_post_id(): int { + $post_id = PHP_INT_MAX; + + while ( get_post( $post_id ) instanceof WP_Post ) { + --$post_id; + } + + return $post_id; + } + + /** + * Tests that an omitted post object does not fall back to the global post. + * + * `get_post()` returns the global post for an empty value, which would flush the + * rewrite rules for whatever post is being displayed rather than the one passed in. + */ + public function test_omitted_post_does_not_fall_back_to_the_global_post(): void { + $this->setExpectedIncorrectUsage( 'wp_random_content_flush_rewrite_rules_for_post' ); + + $GLOBALS['post'] = $this->create_post_object( + array( + 'post_type' => 'page', + 'post_name' => 'random', + ) + ); + + $flushes = new MockAction(); + add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) ); + + wp_random_content_flush_rewrite_rules_for_post( null ); // @phpstan-ignore argument.type (Intentionally omitting the post ID.) + + $this->assertSame( 0, $flushes->get_call_count(), 'An omitted post should not be looked up from the global post.' ); + } + + /** + * Tests that an omitted post object is looked up from the post ID. + * + * The `$post` parameter was added to `after_delete_post` in 5.5.0, but third-party + * code written against the original signature may still fire the action with only + * the post ID. + */ + public function test_omitted_post_is_looked_up_from_the_post_id(): void { + $page_id = $this->create_post( + array( + 'post_type' => 'page', + 'post_name' => 'random', + ) + ); + + $flushes = new MockAction(); + add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) ); + + wp_random_content_flush_rewrite_rules_for_post( $page_id ); + + $this->assertSame( 1, $flushes->get_call_count(), 'A page at the random base should flush the rewrite rules when only its ID is passed.' ); + } + + /** + * Tests that firing `after_delete_post` with only a post ID does not cause a fatal error. + * + * As the post is already deleted, it cannot be looked up from the ID, so the call is + * incorrect usage and the rewrite rules are left alone. + * + * @covers ::_update_posts_count_on_delete + */ + public function test_after_delete_post_fired_with_only_a_post_id_does_not_flush_rewrite_rules(): void { + $post_id = $this->create_post(); + wp_delete_post( $post_id, true ); + + $this->setExpectedIncorrectUsage( 'wp_random_content_flush_rewrite_rules_for_post' ); + + $flushes = new MockAction(); + add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) ); + + do_action( 'after_delete_post', $post_id ); // @phpstan-ignore WordPress.hookParamCountMismatch (Intentionally omitting the post object.) + + $this->assertSame( 0, $flushes->get_call_count(), 'Firing after_delete_post for a deleted post without the post object should not flush the rewrite rules.' ); + } + + public function test_page_named_random_still_flushes_rewrite_rules_when_the_other_arguments_are_invalid(): void { + $page = $this->create_post_object( + array( + 'post_type' => 'page', + 'post_name' => 'random', + ) + ); + + $this->setExpectedIncorrectUsage( 'wp_random_content_flush_rewrite_rules_for_post' ); + + $flushes = new MockAction(); + add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) ); + + do_action( 'wp_after_insert_post', (string) $page->ID, $page, 'yes', false ); + + $this->assertSame( 1, $flushes->get_call_count(), 'A page at the random base should flush the rewrite rules even when the other arguments are invalid.' ); + } + + /** + * Tests that the previous post object is only considered on `wp_after_insert_post`. + * + * `after_delete_post` has no previous post, so a value passed in its place is ignored + * rather than reported as incorrect usage. + */ + public function test_post_before_is_ignored_outside_wp_after_insert_post(): void { + $page_before = $this->create_post_object( + array( + 'post_type' => 'page', + 'post_name' => 'random', + ) + ); + $post = $this->create_post_object(); + + $flushes = new MockAction(); + add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) ); + + wp_random_content_flush_rewrite_rules_for_post( $post->ID, $post, false, 'not a post' ); // @phpstan-ignore argument.type (Intentionally passing an invalid value.) + wp_random_content_flush_rewrite_rules_for_post( $post->ID, $post, false, $page_before ); + + $this->assertSame( 0, $flushes->get_call_count(), 'The previous post should not be considered outside wp_after_insert_post.' ); + } + + public function test_renamed_page_at_random_base_flushes_rewrite_rules_via_post_before(): void { + $page_before = $this->create_post_object( + array( + 'post_type' => 'page', + 'post_name' => 'random', + ) + ); + $post = $this->create_post_object(); + + $flushes = new MockAction(); + add_filter( 'pre_update_option_rewrite_rules', array( $flushes, 'filter' ) ); + + do_action( 'wp_after_insert_post', $post->ID, $post, true, $page_before ); + + $this->assertSame( 1, $flushes->get_call_count(), 'A page previously at the random base should flush the rewrite rules on wp_after_insert_post.' ); + } + /* * Opting out. */