From d40e7626a9e15812af1372e95a890b8130163b0c Mon Sep 17 00:00:00 2001 From: Hug0-Drelon Date: Mon, 24 Aug 2026 07:11:44 +0200 Subject: [PATCH 1/4] Posts: Infer password visibility when a post password is set. Quick Edit posts `sticky` but never posts `visibility`, so `edit_post()` never reached the `case 'password'` branch that drops the sticky flag. A post could end up both sticky and password protected, a combination the block editor itself forbids. Set `visibility` to `password` whenever a post password is present, so the existing branch runs whatever the caller sent. Original patch by Hug0-Drelon in PR #11180. See #64810. --- src/wp-admin/includes/post.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wp-admin/includes/post.php b/src/wp-admin/includes/post.php index c39135316978f..2d8671fe942b7 100644 --- a/src/wp-admin/includes/post.php +++ b/src/wp-admin/includes/post.php @@ -315,6 +315,11 @@ function edit_post( $post_data = null ) { } } + // Password visibility. + if ( ! empty( $post_data['post_password'] ) ) { + $post_data['visibility'] = 'password'; + } + if ( isset( $post_data['visibility'] ) ) { switch ( $post_data['visibility'] ) { case 'public': From 0469fd676c2946d4309ae68e942069219c7f186e Mon Sep 17 00:00:00 2001 From: wppoland Date: Mon, 24 Aug 2026 07:11:44 +0200 Subject: [PATCH 2/4] Tests: Cover unsticking a post given a password without a visibility value. Reproduces the Quick Edit payload: `sticky` is posted, `visibility` is not. Fails on trunk, where the post keeps both the sticky flag and the new password, and passes with the preceding change. See #64810. --- tests/phpunit/tests/admin/includesPost.php | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/phpunit/tests/admin/includesPost.php b/tests/phpunit/tests/admin/includesPost.php index 6c780abb265c8..1eff8b9176322 100644 --- a/tests/phpunit/tests/admin/includesPost.php +++ b/tests/phpunit/tests/admin/includesPost.php @@ -1371,4 +1371,52 @@ public function test_user_get_refreshed_metabox_nonce() { $this->assertNotEmpty( $response['wp-refresh-metabox-loader-nonces']['replace']['_wpnonce'] ); $this->assertNotEmpty( $response['wp-refresh-metabox-loader-nonces']['replace']['metabox_loader_nonce'] ); } + + /** + * Ensures that giving a sticky post a password through edit_post() unsticks it, + * even when the caller never sends an explicit `visibility` value. + * + * The block editor sends `visibility`, so `edit_post()` reaches the + * `case 'password'` branch and drops the `sticky` flag. Quick Edit never sends + * it, so a post could end up both sticky and password protected, a combination + * the editor itself forbids. + * + * @ticket 64810 + * + * @covers ::edit_post + */ + public function test_edit_post_unsticks_a_post_when_a_password_is_set_without_visibility() { + wp_set_current_user( self::$admin_id ); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_author' => self::$admin_id, + ) + ); + + stick_post( $post_id ); + $this->assertTrue( is_sticky( $post_id ), 'The post was not sticky to begin with: check test setup.' ); + + // Mirrors Quick Edit, which posts `sticky` but no `visibility`. + edit_post( + array( + 'post_ID' => $post_id, + 'post_title' => 'Protected and sticky', + 'post_status' => 'publish', + 'post_password' => 'secret', + 'sticky' => 'sticky', + ) + ); + + $this->assertSame( + 'secret', + get_post( $post_id )->post_password, + 'The password was not saved: check test setup.' + ); + $this->assertFalse( + is_sticky( $post_id ), + 'A password protected post was left sticky when no explicit visibility was sent.' + ); + } } From 817f0baad2f30e0434fa2172c1b4f406e1409f22 Mon Sep 17 00:00:00 2001 From: wppoland Date: Mon, 24 Aug 2026 18:52:45 +0200 Subject: [PATCH 3/4] Tests: Cover the remaining visibility branches in edit_post(). Adds the `public`, `password` and `private` cases of the visibility switch, which had no coverage before, and pins the precedence between an inferred visibility and a contradicting one sent by the caller. That last one is a behaviour change worth naming: on trunk, sending `visibility` as `public` alongside a non-empty `post_password` cleared the password. Inferring the visibility from the password applies last, so the password now wins instead. The editors never send that pair, since choosing Public clears the password field, but `edit_post()` is also reachable from bulk edit and Quick Edit. See #64810. --- tests/phpunit/tests/admin/includesPost.php | 141 +++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/tests/phpunit/tests/admin/includesPost.php b/tests/phpunit/tests/admin/includesPost.php index 1eff8b9176322..9a4ffa87ba834 100644 --- a/tests/phpunit/tests/admin/includesPost.php +++ b/tests/phpunit/tests/admin/includesPost.php @@ -1419,4 +1419,145 @@ public function test_edit_post_unsticks_a_post_when_a_password_is_set_without_vi 'A password protected post was left sticky when no explicit visibility was sent.' ); } + + /** + * Ensures that an explicit `public` visibility clears any existing post password. + * + * @ticket 64810 + * + * @covers ::edit_post + */ + public function test_edit_post_clears_the_password_when_visibility_is_public() { + wp_set_current_user( self::$admin_id ); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_author' => self::$admin_id, + 'post_password' => 'secret', + ) + ); + + edit_post( + array( + 'post_ID' => $post_id, + 'post_title' => 'Now public', + 'post_status' => 'publish', + 'post_password' => '', + 'visibility' => 'public', + ) + ); + + $this->assertSame( '', get_post( $post_id )->post_password ); + } + + /** + * Ensures that an explicit `password` visibility unsticks the post. + * + * The companion test above covers the same branch reached through an + * inferred visibility; this one covers a caller that sends it outright. + * + * @ticket 64810 + * + * @covers ::edit_post + */ + public function test_edit_post_unsticks_the_post_when_visibility_is_password() { + wp_set_current_user( self::$admin_id ); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_author' => self::$admin_id, + ) + ); + + stick_post( $post_id ); + + edit_post( + array( + 'post_ID' => $post_id, + 'post_title' => 'Protected', + 'post_status' => 'publish', + 'post_password' => 'secret', + 'sticky' => 'sticky', + 'visibility' => 'password', + ) + ); + + $this->assertSame( 'secret', get_post( $post_id )->post_password, 'The password was not saved: check test setup.' ); + $this->assertFalse( is_sticky( $post_id ), 'A password protected post was left sticky.' ); + } + + /** + * Ensures that an explicit `private` visibility privatises the post, drops any + * password and unsticks it. + * + * @ticket 64810 + * + * @covers ::edit_post + */ + public function test_edit_post_privatises_and_unsticks_the_post_when_visibility_is_private() { + wp_set_current_user( self::$admin_id ); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_author' => self::$admin_id, + ) + ); + + stick_post( $post_id ); + + edit_post( + array( + 'post_ID' => $post_id, + 'post_title' => 'Private', + 'post_status' => 'publish', + 'post_password' => '', + 'sticky' => 'sticky', + 'visibility' => 'private', + ) + ); + + $post = get_post( $post_id ); + + $this->assertSame( 'private', $post->post_status, 'The post was not made private.' ); + $this->assertSame( '', $post->post_password, 'A private post kept a password.' ); + $this->assertFalse( is_sticky( $post_id ), 'A private post was left sticky.' ); + } + + /** + * Documents that a non-empty password wins over a `public` visibility sent alongside it. + * + * Inferring the visibility from the password means the two can now contradict each + * other, and the inferred value is applied last. The editors never send that + * combination, since selecting "Public" clears the password field, but `edit_post()` + * is reachable from bulk edit and Quick Edit too, so the precedence is worth pinning. + * + * @ticket 64810 + * + * @covers ::edit_post + */ + public function test_edit_post_prefers_the_password_over_a_contradicting_public_visibility() { + wp_set_current_user( self::$admin_id ); + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_author' => self::$admin_id, + ) + ); + + edit_post( + array( + 'post_ID' => $post_id, + 'post_title' => 'Contradictory', + 'post_status' => 'publish', + 'post_password' => 'secret', + 'visibility' => 'public', + ) + ); + + $this->assertSame( 'secret', get_post( $post_id )->post_password ); + } } From ccd71dcaa659d3d83c38ab4c4c461b2b16393a6c Mon Sep 17 00:00:00 2001 From: Mariusz Szatkowski <19728801+wppoland@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:08:05 +0200 Subject: [PATCH 4/4] Posts: Only infer the password visibility when the caller sent none. The inference is meant to fill a gap, not to outrank the caller. Reading it after the caller's own value let the two contradict each other, with the inferred one applied last: - `visibility => 'public'` sent with a non-empty password kept the password, where trunk dropped it. - `visibility => 'private'` sent with a password left the post public and password protected instead of private. Quick Edit reaches this pair through `wp_ajax_inline_save()`, which sets `visibility` to `private` whenever "Private" is ticked. Guarding the inference with `! isset( $post_data['visibility'] )` confines the behaviour change to the reported case, a caller that sends no visibility at all. The `private` characterisation test now sends a password alongside, and the precedence test is inverted: an explicit visibility survives. See #64810. --- src/wp-admin/includes/post.php | 4 ++-- tests/phpunit/tests/admin/includesPost.php | 24 ++++++++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/wp-admin/includes/post.php b/src/wp-admin/includes/post.php index 2d8671fe942b7..05ab952d48cf7 100644 --- a/src/wp-admin/includes/post.php +++ b/src/wp-admin/includes/post.php @@ -315,8 +315,8 @@ function edit_post( $post_data = null ) { } } - // Password visibility. - if ( ! empty( $post_data['post_password'] ) ) { + // Infer the password visibility when the caller did not send one of its own. + if ( ! isset( $post_data['visibility'] ) && ! empty( $post_data['post_password'] ) ) { $post_data['visibility'] = 'password'; } diff --git a/tests/phpunit/tests/admin/includesPost.php b/tests/phpunit/tests/admin/includesPost.php index 9a4ffa87ba834..2384a0abbd917 100644 --- a/tests/phpunit/tests/admin/includesPost.php +++ b/tests/phpunit/tests/admin/includesPost.php @@ -1492,6 +1492,11 @@ public function test_edit_post_unsticks_the_post_when_visibility_is_password() { * Ensures that an explicit `private` visibility privatises the post, drops any * password and unsticks it. * + * The password is sent alongside on purpose. Quick Edit posts + * `visibility => 'private'` whenever "Private" is ticked, so this pair is + * reachable from the UI, and inferring `password` from the password instead + * would leave the post public with its password intact. + * * @ticket 64810 * * @covers ::edit_post @@ -1513,7 +1518,7 @@ public function test_edit_post_privatises_and_unsticks_the_post_when_visibility_ 'post_ID' => $post_id, 'post_title' => 'Private', 'post_status' => 'publish', - 'post_password' => '', + 'post_password' => 'secret', 'sticky' => 'sticky', 'visibility' => 'private', ) @@ -1527,18 +1532,17 @@ public function test_edit_post_privatises_and_unsticks_the_post_when_visibility_ } /** - * Documents that a non-empty password wins over a `public` visibility sent alongside it. + * Ensures that a visibility sent by the caller is never overridden by an inferred one. * - * Inferring the visibility from the password means the two can now contradict each - * other, and the inferred value is applied last. The editors never send that - * combination, since selecting "Public" clears the password field, but `edit_post()` - * is reachable from bulk edit and Quick Edit too, so the precedence is worth pinning. + * The inference only fills a gap. A caller that sends `public` alongside a + * non-empty password still reaches `case 'public'` and still has the password + * dropped, exactly as before this change. * * @ticket 64810 * * @covers ::edit_post */ - public function test_edit_post_prefers_the_password_over_a_contradicting_public_visibility() { + public function test_edit_post_keeps_an_explicit_visibility_over_an_inferred_one() { wp_set_current_user( self::$admin_id ); $post_id = self::factory()->post->create( @@ -1558,6 +1562,10 @@ public function test_edit_post_prefers_the_password_over_a_contradicting_public_ ) ); - $this->assertSame( 'secret', get_post( $post_id )->post_password ); + $this->assertSame( + '', + get_post( $post_id )->post_password, + 'An explicit public visibility was overridden by the inferred one.' + ); } }